feat(picker): add series and studio rows from IGDB credits
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m36s

New V37 tables game_credit and game_credit_check store IGDB
collections and developer/publisher companies per game.
A scheduled job backfills existing games (250 per 30 s);
sync, import, manual add, merge and detail page refresh them.
SERIES row: next part of a collection the user played.
STUDIO row: backlog games from developers of top played games.
This commit is contained in:
lobo committed 2026-10-06 20:22:53 +02:00
1 parent 8dd398321c
commit 117ff61730
33 files changed
+1141 -32

No files matched your search

@@ -52,6 +52,23 @@ public final class IgdbResponses {
@JsonProperty("game_modes") java.util.List<Named> gameModes
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record IdNamed(@JsonProperty("id") Long id, @JsonProperty("name") String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record InvolvedCompany(
@JsonProperty("company") IdNamed company,
@JsonProperty("developer") Boolean developer,
@JsonProperty("publisher") Boolean publisher
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Credits(
@JsonProperty("id") Long id,
@JsonProperty("collections") java.util.List<IdNamed> collections,
@JsonProperty("involved_companies") java.util.List<InvolvedCompany> involvedCompanies
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Edition(
@JsonProperty("id") Long id,
@@ -22,6 +22,7 @@ import java.util.Comparator;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
@@ -49,6 +50,17 @@ public class IgdbApiService {
*/
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
public record Series(long id, String name) {}
public record Company(long id, String name, boolean developer, boolean publisher) {}
/**
* @param series IGDB collections, e.g. "Mass Effect"
* @param companies only those that developed or published, one entry per company; both lists are empty when IGDB
* has none
*/
public record Credits(List<Series> series, List<Company> companies) {}
/**
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
*/
@@ -156,6 +168,65 @@ public class IgdbApiService {
}
}
/**
* @return the series and companies of the Steam apps IGDB links to a game, empty lists when that game has none;
* apps IGDB does not link are missing. An app linked to several games gets the credits of all of them. Empty when
* the adapter is off or a request failed.
*/
public Optional<Map<Long, Credits>> getCredits(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, Credits> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
creditsOf(steamByIgdb.keySet()).forEach((igdbId, credits) ->
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, credits, IgdbApiService::union)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
/**
* Like {@link #getCredits}, but for IGDB game ids.
*/
public Optional<Map<Long, Credits>> getCreditsByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Credits> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(creditsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
private static Credits union(Credits a, Credits b) {
Map<Long, Series> series = new LinkedHashMap<>();
Stream.concat(a.series().stream(), b.series().stream()).forEach(s -> series.putIfAbsent(s.id(), s));
return new Credits(List.copyOf(series.values()),
byCompany(Stream.concat(a.companies().stream(), b.companies().stream())));
}
// One entry per company, its roles OR-ed.
private static List<Company> byCompany(Stream<Company> companies) {
Map<Long, Company> byId = new LinkedHashMap<>();
companies.forEach(c -> byId.merge(c.id(), c, (x, y) ->
new Company(x.id(), x.name(), x.developer() || y.developer(), x.publisher() || y.publisher())));
return List.copyOf(byId.values());
}
private static GameTags union(GameTags a, GameTags b) {
return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes()));
}
@@ -470,6 +541,44 @@ public class IgdbApiService {
return result;
}
private Map<Long, Credits> creditsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.Credits[] games = query("/games",
"fields collections.name,involved_companies.company.name,involved_companies.developer,"
+ "involved_companies.publisher; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Credits[].class);
Map<Long, Credits> result = new HashMap<>();
for (IgdbResponses.Credits game : games) {
if (game.id() != null && igdbIds.contains(game.id())) {
result.put(game.id(), new Credits(seriesOf(game.collections()), companiesOf(game.involvedCompanies())));
}
}
return result;
}
private static List<Series> seriesOf(List<IgdbResponses.IdNamed> collections) {
return collections == null ? List.of() : collections.stream()
.filter(IgdbApiService::hasName)
.map(c -> new Series(c.id(), c.name().trim()))
.toList();
}
// Only developers and publishers; porting or supporting companies are dropped.
private static List<Company> companiesOf(List<IgdbResponses.InvolvedCompany> involved) {
return involved == null ? List.of() : byCompany(involved.stream()
.filter(i -> hasName(i.company()))
.map(i -> new Company(i.company().id(), i.company().name().trim(),
Boolean.TRUE.equals(i.developer()), Boolean.TRUE.equals(i.publisher())))
.filter(c -> c.developer() || c.publisher()));
}
private static boolean hasName(IgdbResponses.IdNamed named) {
return named != null && named.id() != null && named.name() != null && !named.name().isBlank();
}
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
@@ -6,27 +6,36 @@ import core_service.modules.library.domain.UserGame;
import java.time.Instant;
/**
* Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST;
* the others are null.
* Why the picker chose a game. The anchor fields are set only for SIMILAR, {@code firstSeenAt} only for LONGEST,
* {@code seriesName} only for SERIES, {@code studioName} only for STUDIO; the others are null.
*
* @param anchorName null when the anchor game has no name
*/
public record PickReasonDto(PickSource source, Long anchorAppid, String anchorName, Integer anchorPlaytimeMinutes,
Instant firstSeenAt) {
Instant firstSeenAt, String seriesName, String studioName) {
public static PickReasonDto similar(UserGame anchor, String anchorName) {
return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null);
return new PickReasonDto(PickSource.SIMILAR, anchor.getAppid(), anchorName, anchor.getPlaytimeMinutes(), null,
null, null);
}
public static PickReasonDto longest(Instant firstSeenAt) {
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt);
return new PickReasonDto(PickSource.LONGEST, null, null, null, firstSeenAt, null, null);
}
public static PickReasonDto random() {
return new PickReasonDto(PickSource.RANDOM, null, null, null, null);
return new PickReasonDto(PickSource.RANDOM, null, null, null, null, null, null);
}
public static PickReasonDto nextUp() {
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null);
return new PickReasonDto(PickSource.NEXT_UP, null, null, null, null, null, null);
}
public static PickReasonDto series(String seriesName) {
return new PickReasonDto(PickSource.SERIES, null, null, null, null, seriesName, null);
}
public static PickReasonDto studio(String studioName) {
return new PickReasonDto(PickSource.STUDIO, null, null, null, null, null, studioName);
}
}
@@ -0,0 +1,7 @@
package core_service.modules.library.domain;
public enum CreditKind {
SERIES,
DEVELOPER,
PUBLISHER
}
@@ -0,0 +1,39 @@
package core_service.modules.library.domain;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.EnumType;
import jakarta.persistence.Enumerated;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* One series or company of a library key, from IGDB (V37). A company that developed and published has one DEVELOPER row.
*/
@Entity
@Table(name = "game_credit")
@IdClass(GameCreditId.class)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCredit {
@Id
@Column(name = "appid")
private Long appid;
@Id
@Enumerated(EnumType.STRING)
@Column(name = "kind", nullable = false, length = 16)
private CreditKind kind;
@Id
@Column(name = "igdb_id")
private Long igdbId;
@Column(name = "name", nullable = false, length = 200)
private String name;
}
@@ -0,0 +1,28 @@
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.Getter;
import lombok.NoArgsConstructor;
import java.time.Instant;
/**
* When IGDB was asked for the credits of one library key (V37). No {@link GameCredit} rows after a check: IGDB knows none.
*/
@Entity
@Table(name = "game_credit_check")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameCreditCheck {
@Id
@Column(name = "appid")
private Long appid;
@Column(name = "checked_at", nullable = false)
private Instant checkedAt;
}
@@ -0,0 +1,20 @@
package core_service.modules.library.domain;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import java.io.Serializable;
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
public class GameCreditId implements Serializable {
private Long appid;
private CreditKind kind;
private Long igdbId;
}
@@ -2,11 +2,14 @@ package core_service.modules.library.domain;
/**
* Where a picker game came from, and the picker row it is shown in. The order of the constants is the order of the
* rows. RANDOM is the mood row.
* rows. SERIES is the next part of a played series, STUDIO a game by a developer the user played. RANDOM is the mood
* row.
*/
public enum PickSource {
NEXT_UP,
SERIES,
SIMILAR,
STUDIO,
LONGEST,
RANDOM
}
@@ -0,0 +1,10 @@
package core_service.modules.library.domain;
import java.time.LocalDate;
/**
* A series the user played a part of.
*
* @param lastPlayedRelease release date of the latest played part; null when no played part has a date
*/
public record PlayedSeries(long seriesId, String name, LocalDate lastPlayedRelease) {}
@@ -0,0 +1,6 @@
package core_service.modules.library.domain;
/**
* A developer of games the user played.
*/
public record PlayedStudio(long companyId, String name) {}
@@ -0,0 +1,33 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameCreditCheck;
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.List;
import java.util.Set;
public interface GameCreditCheckRepository extends JpaRepository<GameCreditCheck, Long> {
@Query("select c.appid from GameCreditCheck c where c.appid in :appids and c.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
@Modifying
@Transactional
@Query(value = "insert into game_credit_check (appid, checked_at) values (:appid, :checkedAt) "
+ "on conflict (appid) do update set checked_at = excluded.checked_at", nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("checkedAt") Instant checkedAt);
/**
* The listed games (not removed, not hidden by any user row) that were never checked, lowest appid first.
*/
@Query(value = "select distinct ug.appid from user_game ug where ug.removed_at is null and ug.hidden_at is null "
+ "and not exists (select 1 from game_credit_check c where c.appid = ug.appid) "
+ "order by ug.appid limit :limit", nativeQuery = true)
List<Long> findUncheckedAppids(@Param("limit") int limit);
}
@@ -0,0 +1,58 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameCredit;
import core_service.modules.library.domain.GameCreditId;
import core_service.modules.library.domain.PlayedSeries;
import core_service.modules.library.domain.PlayedStudio;
import org.springframework.data.domain.Pageable;
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.util.List;
public interface GameCreditRepository extends JpaRepository<GameCredit, GameCreditId> {
@Modifying
@Transactional
@Query("delete from GameCredit c where c.appid = :appid")
void deleteByAppid(@Param("appid") Long appid);
@Modifying
@Transactional
@Query(value = "insert into game_credit (appid, kind, igdb_id, name) values (:appid, :kind, :igdbId, :name) "
+ "on conflict (appid, kind, igdb_id) do update set name = excluded.name", nativeQuery = true)
void insert(@Param("appid") Long appid, @Param("kind") String kind, @Param("igdbId") Long igdbId,
@Param("name") String name);
/**
* The series of the games the user played (playing, finished, completed, or dropped with playtime; a backlog game
* never counts), the most played first.
*/
@Query("select new core_service.modules.library.domain.PlayedSeries(c.igdbId, max(c.name), max(r.releaseDate)) " +
"from CountedUserGame ug join GameCredit c on c.appid = ug.appid " +
"left join GameRelease r on r.appid = ug.appid " +
"where ug.userId = :userId and c.kind = core_service.modules.library.domain.CreditKind.SERIES " +
"and (ug.status in (core_service.modules.library.domain.GameStatus.PLAYING, " +
"core_service.modules.library.domain.GameStatus.FINISHED, " +
"core_service.modules.library.domain.GameStatus.COMPLETED) " +
"or (ug.status = core_service.modules.library.domain.GameStatus.DROPPED and ug.playtimeMinutes > 0)) " +
"group by c.igdbId order by sum(ug.playtimeMinutes) desc, c.igdbId")
List<PlayedSeries> findPlayedSeries(@Param("userId") Long userId, Pageable pageable);
/**
* The developers of the games the user played (same rule as the series: a backlog game never counts), the most
* played first.
*/
@Query("select new core_service.modules.library.domain.PlayedStudio(c.igdbId, max(c.name)) " +
"from CountedUserGame ug join GameCredit c on c.appid = ug.appid " +
"where ug.userId = :userId and c.kind = core_service.modules.library.domain.CreditKind.DEVELOPER " +
"and (ug.status in (core_service.modules.library.domain.GameStatus.PLAYING, " +
"core_service.modules.library.domain.GameStatus.FINISHED, " +
"core_service.modules.library.domain.GameStatus.COMPLETED) " +
"or (ug.status = core_service.modules.library.domain.GameStatus.DROPPED and ug.playtimeMinutes > 0)) " +
"group by c.igdbId order by sum(ug.playtimeMinutes) desc, c.igdbId")
List<PlayedStudio> findPlayedStudios(@Param("userId") Long userId, Pageable pageable);
}
@@ -28,7 +28,7 @@ public class GameMergeRepository {
// The shared rows of a game; steam_game last, since user_game refers to it.
private static final List<String> CATALOG_TABLES = List.of("game_mood", "game_speedrun", "game_time_to_beat",
"game_correction", "broken_image", "game_edition", "game_release", "steam_game");
"game_correction", "broken_image", "game_edition", "game_release", "game_credit", "game_credit_check", "steam_game");
// The paid game kinds; a merge moves their ref, an undo moves it back.
private static final String GAME_KINDS = "('STARTED', 'FINISHED', 'FINISHED_MANUAL', 'COMPLETED')";
@@ -18,6 +18,7 @@ import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.Collection;
import java.util.EnumMap;
@@ -245,6 +246,56 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* Backlog games of one series, the parts released after {@code afterDate} first (earliest first), then the rest by
* release date, unknown dates last. afterDate null: earliest release first.
*/
default List<UserGame> findSeriesGames(Long userId, long seriesId, LocalDate afterDate, Integer maxMinutes,
Collection<Long> excludeAppids, int count) {
return querySeriesGames(userId, seriesId, afterDate, maxMinutes, null, false, excludedOrNone(excludeAppids),
count);
}
@Query(value = "select ug.* from counted_user_game ug " +
"join game_credit c on c.appid = ug.appid and c.kind = 'SERIES' and c.igdb_id = :seriesId " +
"left join game_release r on r.appid = ug.appid " +
"where " + PICKER_FILTERS +
"order by (r.release_date is not null and (cast(:afterDate as date) is null " +
"or r.release_date > :afterDate)) desc, r.release_date asc nulls last, ug.appid limit :count",
nativeQuery = true)
List<UserGame> querySeriesGames(@Param("userId") Long userId,
@Param("seriesId") long seriesId,
@Param("afterDate") LocalDate afterDate,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* Backlog games with a developer or publisher credit of one company, developer credits first, then HIGH priority
* first.
*/
default List<UserGame> findStudioGames(Long userId, long companyId, Integer maxMinutes,
Collection<Long> excludeAppids, int count) {
return queryStudioGames(userId, companyId, maxMinutes, null, false, excludedOrNone(excludeAppids), count);
}
// A company is one credit row per game (the developer row wins), so a game is listed once.
@Query(value = "select ug.* from counted_user_game ug " +
"join game_credit c on c.appid = ug.appid and c.igdb_id = :companyId " +
"and c.kind in ('DEVELOPER', 'PUBLISHER') " +
"where " + PICKER_FILTERS +
"order by case c.kind when 'DEVELOPER' then 0 else 1 end, " + PRIORITY_ORDER +
", random() limit :count", nativeQuery = true)
List<UserGame> queryStudioGames(@Param("userId") Long userId,
@Param("companyId") long companyId,
@Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("speedrun") boolean speedrun,
@Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* The games a user played most, any status, never the ones with no playtime.
*/
@@ -0,0 +1,109 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.CreditKind;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.task.TaskExecutor;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/**
* Stores the IGDB series (collections) and the developers and publishers per library key, the same way
* {@link GameEditionService} stores editions: only for games without a fresh check, and nothing when IGDB fails.
* A company that developed and published a game is stored once, as developer. Companies that only ported or
* supported are not stored.
* <p>
* {@link #backfill} gives the games that were never checked their credits in batches, so the IGDB rate limit holds.
*/
@Service
@Slf4j
public class GameCreditService {
private static final int NAME_LENGTH = 200;
private final IgdbApiService igdbApiService;
private final GameCreditRepository gameCreditRepository;
private final GameCreditCheckRepository gameCreditCheckRepository;
private final TaskExecutor applicationTaskExecutor;
private final TransactionTemplate transaction;
private final long ttlDays;
private final int backfillBatch;
public GameCreditService(IgdbApiService igdbApiService, GameCreditRepository gameCreditRepository,
GameCreditCheckRepository gameCreditCheckRepository, TaskExecutor applicationTaskExecutor,
PlatformTransactionManager transactionManager,
@Value("${app.library.credit-ttl-days:30}") long ttlDays,
@Value("${app.library.credit-backfill-batch:250}") int backfillBatch) {
this.igdbApiService = igdbApiService;
this.gameCreditRepository = gameCreditRepository;
this.gameCreditCheckRepository = gameCreditCheckRepository;
this.applicationTaskExecutor = applicationTaskExecutor;
this.transaction = new TransactionTemplate(transactionManager);
this.ttlDays = ttlDays;
this.backfillBatch = backfillBatch;
}
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
Set<Long> stale = stale(appids);
if (stale.isEmpty()) return;
IgdbLookup.Answer<IgdbApiService.Credits> answer = IgdbLookup.byAppid(stale, igdbApiService::getCredits,
igdbApiService::getCreditsByGameIds);
if (answer.answered().size() < stale.size()) {
log.warn("Credits of {} games not refreshed, IGDB did not answer", stale.size() - answer.answered().size());
}
Instant now = Instant.now();
for (Long appid : answer.answered()) {
IgdbApiService.Credits credits = answer.found().get(appid);
// Replaced as a whole, in one transaction per game: a reader never sees half of the new credits.
transaction.executeWithoutResult(tx -> {
gameCreditRepository.deleteByAppid(appid);
if (credits != null) {
credits.series().forEach(s -> insert(appid, CreditKind.SERIES, s.id(), s.name()));
credits.companies().forEach(c -> insert(appid,
c.developer() ? CreditKind.DEVELOPER : CreditKind.PUBLISHER, c.id(), c.name()));
}
gameCreditCheckRepository.upsert(appid, now);
});
}
}
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
*/
public void refreshInBackground(Collection<Long> appids) {
if (!igdbApiService.isEnabled()) return;
Refreshes.staleInBackground(applicationTaskExecutor, "Credits of " + appids, () -> appids, this::stale,
this::refresh);
}
@Scheduled(initialDelayString = "${app.library.credit-backfill-ms:30000}",
fixedDelayString = "${app.library.credit-backfill-ms:30000}")
public void backfill() {
if (!igdbApiService.isEnabled()) return;
List<Long> appids = gameCreditCheckRepository.findUncheckedAppids(backfillBatch);
if (appids.isEmpty()) return;
log.info("Backfilling credits of {} games", appids.size());
Refreshes.quietly("Credit backfill", () -> refresh(appids));
}
private void insert(Long appid, CreditKind kind, long igdbId, String name) {
gameCreditRepository.insert(appid, kind.name(), igdbId,
name.length() > NAME_LENGTH ? name.substring(0, NAME_LENGTH) : name);
}
private Set<Long> stale(Collection<Long> appids) {
return Refreshes.stale(appids, gameCreditCheckRepository::findFreshAppids, ttlDays);
}
}
@@ -66,6 +66,7 @@ public class GameDetailService {
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final GameSpeedrunRepository gameSpeedrunRepository;
private final SpeedrunService speedrunService;
private final GameMoodsRepository gameMoodsRepository;
@@ -116,6 +117,7 @@ public class GameDetailService {
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
speedrunService.refreshInBackground(List.of(appid));
gameEditionService.refreshInBackground(List.of(appid));
gameCreditService.refreshInBackground(List.of(appid));
}
if (userGame.getSource() == GameSource.MANUAL) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
@@ -53,6 +53,7 @@ public class GameImportWorker {
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameCreditService gameCreditService;
private final Duration stallTime;
// Only a try that started before this app is one a restart stopped.
private final Instant startedAt = Instant.now();
@@ -61,13 +62,14 @@ public class GameImportWorker {
public GameImportWorker(GameImportRepository importRepository, ManualGameService manualGameService,
TimeToBeatService timeToBeatService, MoodService moodService,
ReleaseDateService releaseDateService,
ReleaseDateService releaseDateService, GameCreditService gameCreditService,
@Value("${app.library.import-stall-minutes:5}") long stallMinutes) {
this.importRepository = importRepository;
this.manualGameService = manualGameService;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
this.gameCreditService = gameCreditService;
this.stallTime = Duration.ofMinutes(stallMinutes);
this.matching = new Lane("game-import-match", MATCHING_QUEUE, this::match);
this.saving = new Lane("game-import-save", SAVING_QUEUE, this::save);
@@ -163,9 +165,11 @@ public class GameImportWorker {
timeToBeatService.refresh(appids);
moodService.refresh(appids);
releaseDateService.refresh(appids);
gameCreditService.refresh(appids);
} catch (RuntimeException e) {
// The next sync or page view asks again.
log.warn("Time to beat, moods or release dates of import {} not loaded: {}", importId, e.getMessage());
log.warn("Time to beat, moods, release dates or credits of import {} not loaded: {}", importId,
e.getMessage());
}
if (Thread.currentThread().isInterrupted()) {
return;
@@ -69,17 +69,20 @@ public class GameMergeService {
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameCreditService gameCreditService;
private final TransactionTemplate transaction;
public GameMergeService(GameMergeRepository mergeRepository, AdminAuditRepository adminAuditRepository,
TimeToBeatService timeToBeatService,
MoodService moodService, ReleaseDateService releaseDateService,
GameCreditService gameCreditService,
PlatformTransactionManager transactionManager) {
this.mergeRepository = mergeRepository;
this.adminAuditRepository = adminAuditRepository;
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.releaseDateService = releaseDateService;
this.gameCreditService = gameCreditService;
this.transaction = new TransactionTemplate(transactionManager);
}
@@ -114,6 +117,7 @@ public class GameMergeService {
timeToBeatService.refreshInBackground(List.of(to));
moodService.refreshInBackground(List.of(to));
releaseDateService.refreshInBackground(List.of(to));
gameCreditService.refreshInBackground(List.of(to));
}
return result;
}
@@ -29,6 +29,7 @@ import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserSteamSync;
import core_service.modules.library.repo.ComingGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
@@ -77,6 +78,8 @@ public class LibraryQueryService {
private static final int CLOSEST_GAMES = 5;
private static final int RECENT_GAMES = 5;
private static final int ROW_SIZE = 3;
// Played series or studios a row looks at; each gives at most one card.
private static final int ANCHORS = 6;
private final UserGameRepository userGameRepository;
private final UserSteamSyncRepository userSteamSyncRepository;
@@ -92,6 +95,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository;
private final GameCreditRepository gameCreditRepository;
@Value("${app.library.zone:Europe/Berlin}")
private ZoneId zone;
@@ -111,8 +115,9 @@ public class LibraryQueryService {
}
/**
* One row per {@link PickSource}, in the order of its constants, each with up to three backlog games. Budget and
* exclude apply to every row; the mood only to the RANDOM row. Rows without a game are left out.
* One row per {@link PickSource} (next up, next in series, because you played, same studio, longest waiting,
* mood), in the order of its constants, each with up to three backlog games. Budget and exclude apply to every
* row; the mood only to the RANDOM row. Rows without a game are left out.
* With a mood, also refreshes the backlog's moods or speedrun links in the background for the next pick.
*
* @param only the one row to build (a reroll); null builds all rows
@@ -131,7 +136,9 @@ public class LibraryQueryService {
rows.put(source, switch (source) {
case NEXT_UP -> withReason(userGameRepository.findNextUpGames(userId, maxMinutes, null,
excludeAppids, ROW_SIZE), g -> PickReasonDto.nextUp());
case SERIES -> nextInSeries(userId, maxMinutes, excludeAppids);
case SIMILAR -> becauseYouPlayed(userId, maxMinutes, excludeAppids);
case STUDIO -> sameStudio(userId, maxMinutes, excludeAppids);
case LONGEST -> withReason(userGameRepository.findLongestWaitingGames(userId, maxMinutes, null,
excludeAppids, ROW_SIZE), g -> PickReasonDto.longest(g.getFirstSeenAt()));
case RANDOM -> withReason(userGameRepository.findPickerGames(userId, maxMinutes, mood,
@@ -158,8 +165,7 @@ public class LibraryQueryService {
}
/**
* For each of the user's most played games, the first similar backlog game no earlier anchor took. An anchor
* without a candidate is skipped.
* For each of the user's most played games, the first similar backlog game no earlier anchor took.
*/
private List<Map.Entry<UserGame, PickReasonDto>> becauseYouPlayed(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
@@ -167,13 +173,48 @@ public class LibraryQueryService {
Map<Long, String> names = steamGameRepository.findAllById(anchors.stream().map(UserGame::getAppid).toList())
.stream().filter(g -> g.getName() != null)
.collect(Collectors.toMap(SteamGame::getAppid, SteamGame::getName));
return onePerAnchor(anchors,
anchor -> userGameRepository.findSimilarGames(userId, anchor.getAppid(), maxMinutes, null,
excludeAppids, ROW_SIZE),
anchor -> PickReasonDto.similar(anchor, names.get(anchor.getAppid())));
}
/**
* For each series the user played (most played first), the first backlog part no earlier series took: the parts
* released after the latest played part come first.
*/
private List<Map.Entry<UserGame, PickReasonDto>> nextInSeries(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
return onePerAnchor(gameCreditRepository.findPlayedSeries(userId, PageRequest.of(0, ANCHORS)),
anchor -> userGameRepository.findSeriesGames(userId, anchor.seriesId(), anchor.lastPlayedRelease(),
maxMinutes, excludeAppids, ROW_SIZE),
anchor -> PickReasonDto.series(anchor.name()));
}
/**
* For each developer of the user's played games (most played first), the first backlog game no earlier studio
* took. One card per studio, so a big company adds one card at most.
*/
private List<Map.Entry<UserGame, PickReasonDto>> sameStudio(Long userId, Integer maxMinutes,
List<Long> excludeAppids) {
return onePerAnchor(gameCreditRepository.findPlayedStudios(userId, PageRequest.of(0, ANCHORS)),
anchor -> userGameRepository.findStudioGames(userId, anchor.companyId(), maxMinutes, excludeAppids,
ROW_SIZE),
anchor -> PickReasonDto.studio(anchor.name()));
}
/**
* Per anchor, in order, the first candidate no earlier anchor took, until the row is full. An anchor without a
* candidate is skipped.
*/
private static <A> List<Map.Entry<UserGame, PickReasonDto>> onePerAnchor(
List<A> anchors, Function<A, List<UserGame>> candidates, Function<A, PickReasonDto> reason) {
Set<Long> used = new HashSet<>();
List<Map.Entry<UserGame, PickReasonDto>> picks = new ArrayList<>();
for (UserGame anchor : anchors) {
userGameRepository.findSimilarGames(userId, anchor.getAppid(), maxMinutes, null, excludeAppids, ROW_SIZE)
.stream().filter(g -> used.add(g.getAppid())).findFirst()
.ifPresent(g -> picks.add(
Map.entry(g, PickReasonDto.similar(anchor, names.get(anchor.getAppid())))));
for (A anchor : anchors) {
if (picks.size() == ROW_SIZE) break;
candidates.apply(anchor).stream().filter(g -> used.add(g.getAppid())).findFirst()
.ifPresent(g -> picks.add(Map.entry(g, reason.apply(anchor))));
}
return picks;
}
@@ -67,6 +67,7 @@ public class LibrarySyncWorker {
private final MoodService moodService;
private final ReleaseDateService releaseDateService;
private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ApplicationEventPublisher eventPublisher;
private final TransactionTemplate transaction;
@@ -158,6 +159,7 @@ public class LibrarySyncWorker {
releaseDateService.refresh(igdbAppids);
moodService.refresh(igdbAppids);
gameEditionService.refresh(igdbAppids);
gameCreditService.refresh(igdbAppids);
finalStatus = Status.OK;
} catch (Exception e) {
log.error("Sync failed for user {}", userId, e);
@@ -67,13 +67,15 @@ public class ManualGameService {
private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final GameEditionService gameEditionService;
private final GameCreditService gameCreditService;
private final ReleaseDateService releaseDateService;
private final TransactionTemplate transaction;
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, GameMergeRepository mergeRepository,
TimeToBeatService timeToBeatService, MoodService moodService,
GameEditionService gameEditionService, ReleaseDateService releaseDateService,
GameEditionService gameEditionService, GameCreditService gameCreditService,
ReleaseDateService releaseDateService,
PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService;
this.userGameRepository = userGameRepository;
@@ -82,6 +84,7 @@ public class ManualGameService {
this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.gameEditionService = gameEditionService;
this.gameCreditService = gameCreditService;
this.releaseDateService = releaseDateService;
this.transaction = new TransactionTemplate(transactionManager);
}
@@ -175,6 +178,7 @@ public class ManualGameService {
timeToBeatService.refreshInBackground(List.of(added.appid()));
moodService.refreshInBackground(List.of(added.appid()));
gameEditionService.refreshInBackground(List.of(added.appid()));
gameCreditService.refreshInBackground(List.of(added.appid()));
releaseDateService.refreshInBackground(List.of(added.appid()));
}
return result;
+4
View File
@@ -64,6 +64,10 @@ app:
achievement-ttl-hours: 1
schema-ttl-days: 30
speedrun-ttl-days: 30
# Existing games get their IGDB series and companies in batches; 2 IGDB requests per batch.
credit-ttl-days: 30
credit-backfill-ms: 30000
credit-backfill-batch: 250
edition-ttl-days: 30
release-ttl-days: 30
# Reported broken pictures, repaired after the nightly sync.
@@ -0,0 +1,15 @@
-- The series (IGDB collection) and the companies (developer, publisher) of a game, per library key, shared by all
-- users and refreshed like game_edition. A company that developed and published a game has one DEVELOPER row.
CREATE TABLE game_credit (
appid BIGINT NOT NULL,
kind VARCHAR(16) NOT NULL CHECK (kind IN ('SERIES', 'DEVELOPER', 'PUBLISHER')),
igdb_id BIGINT NOT NULL,
name VARCHAR(200) NOT NULL,
PRIMARY KEY (appid, kind, igdb_id)
);
CREATE INDEX game_credit_igdb_idx ON game_credit (kind, igdb_id);
-- When IGDB was asked for the credits of a game; a row without game_credit rows means IGDB knows none.
CREATE TABLE game_credit_check (
appid BIGINT PRIMARY KEY,
checked_at TIMESTAMPTZ NOT NULL
);
@@ -110,6 +110,71 @@ class IgdbApiServiceTest extends AbstractUnitTest {
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
}
@Test
void getCredits_ReturnsSeriesAndCompaniesPerSteamApp() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(
containsString("fields collections.name,involved_companies.company.name,"
+ "involved_companies.developer,involved_companies.publisher;"),
containsString("id = (72,233)"))))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": ["
+ "{\"id\": 1, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true},"
+ "{\"id\": 3, \"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true},"
+ "{\"id\": 2, \"company\": {\"id\": 6, \"name\": \"Port Studio\"}, \"developer\": false, \"publisher\": false}]},"
+ " {\"id\": 233}]", MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.Credits> credits = service.getCredits(List.of(620L, 220L)).orElseThrow();
assertThat(credits).containsOnly(
Map.entry(620L, new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)))),
Map.entry(220L, new IgdbApiService.Credits(List.of(), List.of())));
}
@Test
void getCredits_TwoIgdbGamesOfOneApp_UnionByIds() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"620\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andRespond(withSuccess("[{\"id\": 72, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"developer\": true}]},"
+ " {\"id\": 233, \"collections\": [{\"id\": 10, \"name\": \"Mass Effect\"}],"
+ " \"involved_companies\": [{\"company\": {\"id\": 5, \"name\": \"BioWare\"}, \"publisher\": true}]}]",
MediaType.APPLICATION_JSON));
IgdbApiService.Credits credits = service.getCredits(List.of(620L)).orElseThrow().get(620L);
assertThat(credits.series()).containsExactly(new IgdbApiService.Series(10, "Mass Effect"));
assertThat(credits.companies()).containsExactly(new IgdbApiService.Company(5, "BioWare", true, true));
}
@Test
void getCreditsByGameIds_AsksTheGamesDirectly() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (119388)")))
.andRespond(withSuccess("[{\"id\": 119388, \"collections\": [{\"id\": 3, \"name\": \"Zelda\"}]}]",
MediaType.APPLICATION_JSON));
assertThat(service.getCreditsByGameIds(List.of(119388L)).orElseThrow().get(119388L).series())
.containsExactly(new IgdbApiService.Series(3, "Zelda"));
}
@Test
void getCredits_ServerError_IsEmpty() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getCredits(List.of(620L))).isEmpty();
}
@Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
expectToken();
@@ -191,6 +191,20 @@ class GameMergeFlowTest extends AbstractIntegrationTest {
assertThat(audit()).hasSize(1).first().asString().contains("GAME_MERGE");
}
@Test
void mergeDropsTheCreditsOfTheMergedKey() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
jdbc.update("delete from game_credit");
jdbc.update("delete from game_credit_check");
jdbc.update("insert into game_credit (appid, kind, igdb_id, name) values (?, 'SERIES', 10, 'Hades')", MANUAL);
jdbc.update("insert into game_credit_check (appid, checked_at) values (?, now())", MANUAL);
merge(Role.AUDITOR, MANUAL, STEAM).andExpect(status().isOk());
assertThat(jdbc.queryForObject("select count(*) from game_credit where appid = ?", Integer.class, MANUAL)).isZero();
assertThat(jdbc.queryForObject("select count(*) from game_credit_check where appid = ?", Integer.class, MANUAL)).isZero();
}
@Test
void merge_IntoAKeyWithItsOwnSet_JoinsBothSets() throws Exception {
manualRow(AUDITOR_ID, MANUAL, GameStatus.BACKLOG);
@@ -7,6 +7,8 @@ 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.UserGameId;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameReleaseRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -76,9 +78,17 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
@Autowired
private UserGameAchievementMetaRepository achievementMetaRepository;
@Autowired
private GameCreditRepository gameCreditRepository;
@Autowired
private GameCreditCheckRepository gameCreditCheckRepository;
@BeforeEach
void clean() {
gameReleaseRepository.deleteAll();
gameCreditRepository.deleteAll();
gameCreditCheckRepository.deleteAll();
// A game with unlocked achievements is never coming; other test classes leave such rows.
achievementMetaRepository.deleteAll();
userGameRepository.deleteAll();
@@ -103,6 +113,14 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
}
}
private void credit(long appid, String kind, long igdbId, String name) {
gameCreditRepository.insert(appid, kind, igdbId, name);
}
private void release(long appid, LocalDate date) {
gameReleaseRepository.upsert(appid, date, false, Instant.now());
}
private void putOrder(Long... appids) throws Exception {
mockMvc.perform(put("/api/library/backlog").header(ClientHeaderFilter.CLIENT_HEADER, "test")
.contentType(MediaType.APPLICATION_JSON)
@@ -247,13 +265,20 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
}
gameMoodsRepository.upsert(1L, new String[]{"STORY"}, new String[0], false, Instant.now());
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, new String[0], false, Instant.now());
credit(1L, "SERIES", 100L, "Series");
credit(12L, "SERIES", 100L, "Series");
credit(1L, "DEVELOPER", 200L, "Studio");
credit(13L, "DEVELOPER", 200L, "Studio");
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.categories[*].source", contains("NEXT_UP", "SIMILAR", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.categories[*].source",
contains("NEXT_UP", "SERIES", "SIMILAR", "STUDIO", "LONGEST", "RANDOM")))
.andExpect(jsonPath("$.categories[*].games.length()", everyItem(lessThanOrEqualTo(3))))
.andExpect(jsonPath("$.categories[0].games[*].reason.source", everyItem(is("NEXT_UP"))))
.andExpect(jsonPath("$.categories[2].games[0].reason.firstSeenAt").exists());
.andExpect(jsonPath("$.categories[1].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.categories[3].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.categories[4].games[0].reason.firstSeenAt").exists());
}
@Test
@@ -329,12 +354,155 @@ class LibraryControllerPickerTest extends AbstractMockedLibraryServicesTest {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 3000);
game(30L, GameStatus.BACKLOG, null, 60);
game(5L, GameStatus.FINISHED, null, null, 600, SEEN);
for (long appid : new long[]{5L, 10L, 20L, 30L}) {
credit(appid, "SERIES", 100L, "Series");
credit(appid, "DEVELOPER", 200L, "Studio");
}
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300").param("exclude", "30"))
.andExpect(jsonPath(row("SERIES"), contains(10)))
.andExpect(jsonPath(row("STUDIO"), contains(10)))
.andExpect(jsonPath("$.categories[*].games[*].appid", everyItem(is(10))))
.andExpect(jsonPath("$.categories[*].games[*].appid", not(hasItem(20))));
}
@Test
void picker_NextInSeries_PicksThePartReleasedAfterThePlayedOne() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
game(3L, GameStatus.BACKLOG, null, null);
game(4L, GameStatus.BACKLOG, null, null);
for (long appid = 1; appid <= 4; appid++) {
credit(appid, "SERIES", 100L, "Mass Effect");
}
release(1L, LocalDate.of(2007, 11, 20));
release(2L, LocalDate.of(2010, 1, 26));
release(3L, LocalDate.of(2012, 3, 6));
release(4L, LocalDate.of(2005, 1, 1));
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("SERIES"))
.andExpect(jsonPath("$.categories[0].games[0].reason.seriesName").value("Mass Effect"));
}
@Test
void picker_NextInSeries_WithoutDates_StillPicksAPart() throws Exception {
game(1L, GameStatus.PLAYING, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(2)));
}
@Test
void picker_NextInSeries_OneGamePerSeriesMostPlayedSeriesFirst() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 100, SEEN);
game(2L, GameStatus.FINISHED, null, null, 900, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
game(21L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Small");
credit(2L, "SERIES", 200L, "Big");
credit(11L, "SERIES", 100L, "Small");
credit(12L, "SERIES", 100L, "Small");
credit(13L, "SERIES", 100L, "Small");
credit(21L, "SERIES", 200L, "Big");
mockMvc.perform(get("/api/library/picker").param("category", "SERIES"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(21, 11)))
.andExpect(jsonPath("$.categories[0].games[*].reason.seriesName", contains("Big", "Small")));
}
@Test
void picker_NextInSeries_SeriesOnlyInBacklog_IsNotPlayed() throws Exception {
game(1L, GameStatus.BACKLOG, null, null, 500, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_NextInSeries_NeverPicksAComingGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.BACKLOG, null, null);
credit(1L, "SERIES", 100L, "Mass Effect");
credit(2L, "SERIES", 100L, "Mass Effect");
gameReleaseRepository.upsert(2L, LocalDate.now(ZoneId.of("Europe/Berlin")).plusDays(3), false, Instant.now());
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))));
}
@Test
void picker_SameStudio_PrefersDeveloperAndOneGamePerStudio() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
game(12L, GameStatus.BACKLOG, null, null);
game(13L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(12L, "PUBLISHER", 5L, "BioWare");
credit(13L, "PUBLISHER", 6L, "EA");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games[*].appid", contains(11)))
.andExpect(jsonPath("$.categories[0].games[0].reason.source").value("STUDIO"))
.andExpect(jsonPath("$.categories[0].games[0].reason.studioName").value("BioWare"));
}
@Test
void picker_SameStudio_PublisherOfPlayedGameIsNoAnchor() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "PUBLISHER", 6L, "EA");
credit(11L, "DEVELOPER", 6L, "EA");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_SameStudio_NeverRepeatsAGame() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(2L, GameStatus.FINISHED, null, null, 500, SEEN);
game(11L, GameStatus.BACKLOG, null, null);
credit(1L, "DEVELOPER", 5L, "BioWare");
credit(2L, "DEVELOPER", 7L, "Other");
credit(11L, "DEVELOPER", 5L, "BioWare");
credit(11L, "DEVELOPER", 7L, "Other");
mockMvc.perform(get("/api/library/picker").param("category", "STUDIO"))
.andExpect(jsonPath("$.categories[0].games", hasSize(1)));
}
@Test
void sameStudio_AStartedBacklogGameIsNotItsOwnAnchor() throws Exception {
game(11L, GameStatus.BACKLOG, null, null, 300, SEEN);
credit(11L, "DEVELOPER", 5L, "BioWare");
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_NoCredits_LeavesOutSeriesAndStudioRows() throws Exception {
game(1L, GameStatus.FINISHED, null, null, 600, SEEN);
game(10L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("SERIES"))))
.andExpect(jsonPath("$.categories[*].source", not(hasItem("STUDIO"))));
}
@Test
void picker_Category_ReturnsOnlyThatRow() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
@@ -0,0 +1,106 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.PlayedSeries;
import core_service.modules.library.domain.PlayedStudio;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import java.time.Instant;
import java.time.LocalDate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class GameCreditRepositoryTest extends AbstractPersistenceTest {
private static final long USER_ID = 1L;
private static final PageRequest TOP = PageRequest.of(0, 6);
@Autowired
private GameCreditRepository repository;
@Autowired
private GameCreditCheckRepository checkRepository;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameReleaseRepository gameReleaseRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
repository.deleteAll();
checkRepository.deleteAll();
gameReleaseRepository.deleteAll();
}
private UserGame game(long appid, GameStatus status, int playtime) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, playtime, Instant.now(), Instant.now());
game.setStatus(status);
return userGameRepository.saveAndFlush(game);
}
@Test
void findPlayedSeries_MostPlayedFirstWithTheLatestPlayedRelease() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 300);
game(3L, GameStatus.BACKLOG, 5000);
game(4L, GameStatus.DROPPED, 0);
game(5L, GameStatus.PLAYING, 100);
gameReleaseRepository.upsert(1L, LocalDate.of(2007, 11, 20), false, Instant.now());
gameReleaseRepository.upsert(2L, LocalDate.of(2010, 1, 26), false, Instant.now());
repository.insert(1L, "SERIES", 10L, "Mass Effect");
repository.insert(2L, "SERIES", 10L, "Mass Effect");
repository.insert(3L, "SERIES", 10L, "Mass Effect");
repository.insert(4L, "SERIES", 20L, "Dropped Series");
repository.insert(5L, "SERIES", 30L, "Portal");
List<PlayedSeries> series = repository.findPlayedSeries(USER_ID, TOP);
assertThat(series).containsExactly(
new PlayedSeries(10L, "Mass Effect", LocalDate.of(2010, 1, 26)),
new PlayedSeries(30L, "Portal", null));
}
@Test
void findPlayedStudios_DevelopersOfPlayedGamesOnly() {
game(1L, GameStatus.FINISHED, 600);
game(2L, GameStatus.FINISHED, 900);
game(3L, GameStatus.BACKLOG, 0);
repository.insert(1L, "DEVELOPER", 5L, "BioWare");
repository.insert(1L, "PUBLISHER", 6L, "EA");
repository.insert(2L, "PUBLISHER", 6L, "EA");
repository.insert(3L, "DEVELOPER", 7L, "Unplayed Studio");
assertThat(repository.findPlayedStudios(USER_ID, TOP)).containsExactly(new PlayedStudio(5L, "BioWare"));
}
@Test
void findUncheckedAppids_SkipsCheckedRemovedAndHidden() {
game(1L, GameStatus.BACKLOG, 0);
game(2L, GameStatus.BACKLOG, 0);
game(3L, GameStatus.BACKLOG, 0);
game(4L, GameStatus.BACKLOG, 0);
game(5L, GameStatus.BACKLOG, 0);
userGameRepository.findById(new UserGameId(USER_ID, 3L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.findById(new UserGameId(USER_ID, 4L)).orElseThrow().setHiddenAt(Instant.now());
userGameRepository.flush();
checkRepository.upsert(2L, Instant.now());
assertThat(checkRepository.findUncheckedAppids(10)).containsExactly(1L, 5L);
assertThat(checkRepository.findUncheckedAppids(1)).containsExactly(1L);
}
}
@@ -0,0 +1,167 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameCreditCheckRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.springframework.core.task.SyncTaskExecutor;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.SimpleTransactionStatus;
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.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
class GameCreditServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameCreditRepository creditRepository;
@Mock
private GameCreditCheckRepository checkRepository;
private GameCreditService service;
@BeforeEach
void setUp() {
PlatformTransactionManager transactionManager = mock(PlatformTransactionManager.class);
lenient().when(transactionManager.getTransaction(any())).thenReturn(new SimpleTransactionStatus());
service = new GameCreditService(igdbApiService, creditRepository, checkRepository, new SyncTaskExecutor(),
transactionManager, 30, 250);
}
private static IgdbApiService.Credits credits() {
return new IgdbApiService.Credits(List.of(new IgdbApiService.Series(10, "Mass Effect")),
List.of(new IgdbApiService.Company(5, "BioWare", true, false),
new IgdbApiService.Company(6, "EA", false, true)));
}
@Test
void refresh_ReplacesTheCreditsAndWritesTheCheckOfAnsweredGames() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(620L, credits())));
service.refresh(List.of(620L, 700L));
verify(creditRepository).deleteByAppid(620L);
verify(creditRepository).insert(620L, "SERIES", 10L, "Mass Effect");
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository).insert(620L, "PUBLISHER", 6L, "EA");
verify(checkRepository).upsert(eq(620L), any(Instant.class));
}
@Test
void refresh_StoresADeveloperThatAlsoPublishedOnce() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
IgdbApiService.Credits both = new IgdbApiService.Credits(List.of(),
List.of(new IgdbApiService.Company(5, "BioWare", true, true)));
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of(620L, both)));
service.refresh(List.of(620L));
verify(creditRepository).insert(620L, "DEVELOPER", 5L, "BioWare");
verify(creditRepository, never()).insert(anyLong(), eq("PUBLISHER"), anyLong(), anyString());
}
@Test
void refresh_GameUnknownToIgdb_WritesOnlyTheCheck() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.of(Map.of()));
service.refresh(List.of(700L));
verify(creditRepository).deleteByAppid(700L);
verify(creditRepository, never()).insert(anyLong(), anyString(), anyLong(), anyString());
verify(checkRepository).upsert(eq(700L), any(Instant.class));
}
@Test
void refresh_IgdbFailed_WritesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenReturn(Optional.empty());
service.refresh(List.of(700L));
verify(checkRepository, never()).upsert(any(), any());
verifyNoInteractions(creditRepository);
}
@Test
void backfill_RefreshesTheNextUncheckedBatchOnly() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L, 2L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(Set.of(1L, 2L))).thenReturn(Optional.of(Map.of(1L, credits())));
service.backfill();
verify(checkRepository).upsert(eq(1L), any(Instant.class));
verify(checkRepository).upsert(eq(2L), any(Instant.class));
}
@Test
void backfill_NothingUnchecked_CallsNoIgdb() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of());
service.backfill();
verify(igdbApiService, never()).getCredits(any());
verify(igdbApiService, never()).getCreditsByGameIds(any());
}
@Test
void backfill_IgdbDown_WritesNothingAndDoesNotThrow() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findUncheckedAppids(250)).thenReturn(List.of(1L));
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getCredits(any())).thenThrow(new IllegalStateException("down"));
service.backfill();
verify(checkRepository, never()).upsert(any(), any());
}
@Test
void backfill_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.backfill();
verifyNoInteractions(checkRepository);
}
@Test
void refreshInBackground_AllFresh_QueuesNothing() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(checkRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(620L));
service.refreshInBackground(List.of(620L));
verify(igdbApiService, never()).getCredits(any());
}
}
@@ -56,7 +56,7 @@ class GameImportWorkerTest {
.thenReturn(Optional.of(7L), Optional.empty());
when(repository.findStalled(any())).thenReturn(Map.of());
GameImportWorker worker = new GameImportWorker(repository, mock(ManualGameService.class),
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), 5);
mock(TimeToBeatService.class), mock(MoodService.class), mock(ReleaseDateService.class), mock(GameCreditService.class), 5);
try {
worker.wakeMatching();
verify(repository, timeout(2000)).findNext(GameImportWorker.MATCHING_QUEUE);
@@ -132,6 +132,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock
private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock
private ApplicationEventPublisher eventPublisher;
@@ -576,6 +579,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(moodService).refresh(List.of(220L, -1025L));
verify(releaseDateService).refresh(List.of(220L, -1025L));
verify(gameEditionService).refresh(List.of(220L, -1025L));
verify(gameCreditService).refresh(List.of(220L, -1025L));
}
@Test
@@ -53,6 +53,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock
private GameEditionService gameEditionService;
@Mock
private GameCreditService gameCreditService;
@Mock
private ReleaseDateService releaseDateService;
@@ -64,7 +67,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach
void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, mergeRepository, timeToBeatService,
moodService, gameEditionService, releaseDateService, transactionManager);
moodService, gameEditionService, gameCreditService, releaseDateService, transactionManager);
}
@Test
@@ -30,7 +30,7 @@ import java.util.concurrent.atomic.AtomicReference;
// Every cached context keeps its own pool: small pools keep them all below Postgres' 100 clients.
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT,
properties = {"app.library.nightly-sync-cron=-", "app.library.image-repair-cron=-", "app.library.merge-undo-purge-cron=-", "app.inbox.purge-cron=-", "app.follow.digest-ms=86400000", "app.rewards.settle-cron=-", "app.speedrun.enabled=false",
"app.library.import-watchdog-ms=86400000",
"app.library.import-watchdog-ms=86400000", "app.library.credit-backfill-ms=86400000",
"spring.datasource.hikari.maximum-pool-size=4"})
@ActiveProfiles("dev")
public abstract class AbstractIntegrationTest {