From 117ff6173093fb95388df73e4a3e65ef41c91901 Mon Sep 17 00:00:00 2001 From: lobo Date: Tue, 6 Oct 2026 20:22:53 +0200 Subject: [PATCH] feat(picker): add series and studio rows from IGDB credits 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. --- readme.md | 19 +- .../api/dto/response/IgdbResponses.java | 17 ++ .../igdb_adapter/service/IgdbApiService.java | 109 +++++++++++ .../library/api/dto/PickReasonDto.java | 23 ++- .../modules/library/domain/CreditKind.java | 7 + .../modules/library/domain/GameCredit.java | 39 ++++ .../library/domain/GameCreditCheck.java | 28 +++ .../modules/library/domain/GameCreditId.java | 20 ++ .../modules/library/domain/PickSource.java | 5 +- .../modules/library/domain/PlayedSeries.java | 10 + .../modules/library/domain/PlayedStudio.java | 6 + .../repo/GameCreditCheckRepository.java | 33 ++++ .../library/repo/GameCreditRepository.java | 58 ++++++ .../library/repo/GameMergeRepository.java | 2 +- .../library/repo/UserGameRepository.java | 51 ++++++ .../library/service/GameCreditService.java | 109 +++++++++++ .../library/service/GameDetailService.java | 2 + .../library/service/GameImportWorker.java | 8 +- .../library/service/GameMergeService.java | 4 + .../library/service/LibraryQueryService.java | 59 +++++- .../library/service/LibrarySyncWorker.java | 2 + .../library/service/ManualGameService.java | 6 +- src/main/resources/application.yml | 4 + .../db/migration/V37__Game_Credits.sql | 15 ++ .../service/IgdbApiServiceTest.java | 65 +++++++ .../library/api/GameMergeFlowTest.java | 14 ++ .../api/LibraryControllerPickerTest.java | 172 +++++++++++++++++- .../repo/GameCreditRepositoryTest.java | 106 +++++++++++ .../service/GameCreditServiceTest.java | 167 +++++++++++++++++ .../library/service/GameImportWorkerTest.java | 2 +- .../service/LibrarySyncWorkerTest.java | 4 + .../service/ManualGameServiceTest.java | 5 +- .../shared/test/AbstractIntegrationTest.java | 2 +- 33 files changed, 1141 insertions(+), 32 deletions(-) create mode 100644 src/main/java/core_service/modules/library/domain/CreditKind.java create mode 100644 src/main/java/core_service/modules/library/domain/GameCredit.java create mode 100644 src/main/java/core_service/modules/library/domain/GameCreditCheck.java create mode 100644 src/main/java/core_service/modules/library/domain/GameCreditId.java create mode 100644 src/main/java/core_service/modules/library/domain/PlayedSeries.java create mode 100644 src/main/java/core_service/modules/library/domain/PlayedStudio.java create mode 100644 src/main/java/core_service/modules/library/repo/GameCreditCheckRepository.java create mode 100644 src/main/java/core_service/modules/library/repo/GameCreditRepository.java create mode 100644 src/main/java/core_service/modules/library/service/GameCreditService.java create mode 100644 src/main/resources/db/migration/V37__Game_Credits.sql create mode 100644 src/test/java/core_service/modules/library/repo/GameCreditRepositoryTest.java create mode 100644 src/test/java/core_service/modules/library/service/GameCreditServiceTest.java diff --git a/readme.md b/readme.md index 9bad1c1..2ce9ffa 100644 --- a/readme.md +++ b/readme.md @@ -21,12 +21,19 @@ To Be Played is split into three repositories: can be taken over; its own finish then changes nothing. - **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority, filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. -- **Picker**: four rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order: - `NEXT_UP` (the first games of the user's own backlog order), `SIMILAR` ("because you played": for each of the 3 most - played games the first backlog game sharing its moods, no game twice), `LONGEST` (oldest `firstSeenAt`) and `RANDOM` - (the mood row: random, HIGH priority first, then MEDIUM, then the rest). The mood filters only the `RANDOM` row; budget - and exclude filters apply to every row. A row with no game is left out. A game can show in more than one row. - Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`). +- **Picker**: six rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order: + `NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played + IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR` + ("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice), + `STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that + company, developer credits first; publishers are never anchors, so a big publisher adds at most one card), + `LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the + rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is + left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game + (`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill + (`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over + time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for + `SERIES`, `studioName` for `STUDIO` or `firstSeenAt` for `LONGEST`). Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`, `CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com. - **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most diff --git a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java index d3c3fed..d7f5266 100644 --- a/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java +++ b/src/main/java/core_service/modules/igdb_adapter/api/dto/response/IgdbResponses.java @@ -52,6 +52,23 @@ public final class IgdbResponses { @JsonProperty("game_modes") java.util.List 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 collections, + @JsonProperty("involved_companies") java.util.List involvedCompanies + ) {} + @JsonIgnoreProperties(ignoreUnknown = true) public record Edition( @JsonProperty("id") Long id, diff --git a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java index ab7868f..0c509f1 100644 --- a/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java +++ b/src/main/java/core_service/modules/igdb_adapter/service/IgdbApiService.java @@ -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 genres, List themes, List 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, List 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> getCredits(Collection steamAppids) { + if (!isEnabled() || steamAppids.isEmpty()) { + return Optional.empty(); + } + try { + List appids = List.copyOf(steamAppids); + Map result = new HashMap<>(); + for (int from = 0; from < appids.size(); from += BATCH) { + Map> 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> getCreditsByGameIds(Collection igdbIds) { + if (!isEnabled() || igdbIds.isEmpty()) { + return Optional.empty(); + } + try { + List ids = List.copyOf(igdbIds); + Map result = new HashMap<>(); + for (int from = 0; from < ids.size(); from += BATCH) { + 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 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 byCompany(Stream companies) { + Map 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 creditsOf(Collection 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 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 seriesOf(List 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 companiesOf(List 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 editionsOf(Collection igdbIds) { if (igdbIds.isEmpty()) { return Map.of(); diff --git a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java index 7ad2936..e892f5a 100644 --- a/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java +++ b/src/main/java/core_service/modules/library/api/dto/PickReasonDto.java @@ -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); } } diff --git a/src/main/java/core_service/modules/library/domain/CreditKind.java b/src/main/java/core_service/modules/library/domain/CreditKind.java new file mode 100644 index 0000000..6375350 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/CreditKind.java @@ -0,0 +1,7 @@ +package core_service.modules.library.domain; + +public enum CreditKind { + SERIES, + DEVELOPER, + PUBLISHER +} diff --git a/src/main/java/core_service/modules/library/domain/GameCredit.java b/src/main/java/core_service/modules/library/domain/GameCredit.java new file mode 100644 index 0000000..c789bb0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameCredit.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/GameCreditCheck.java b/src/main/java/core_service/modules/library/domain/GameCreditCheck.java new file mode 100644 index 0000000..5cf29e3 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameCreditCheck.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/GameCreditId.java b/src/main/java/core_service/modules/library/domain/GameCreditId.java new file mode 100644 index 0000000..7502ba6 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/GameCreditId.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/PickSource.java b/src/main/java/core_service/modules/library/domain/PickSource.java index ab8d5ef..da96936 100644 --- a/src/main/java/core_service/modules/library/domain/PickSource.java +++ b/src/main/java/core_service/modules/library/domain/PickSource.java @@ -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 } diff --git a/src/main/java/core_service/modules/library/domain/PlayedSeries.java b/src/main/java/core_service/modules/library/domain/PlayedSeries.java new file mode 100644 index 0000000..c4436af --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PlayedSeries.java @@ -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) {} diff --git a/src/main/java/core_service/modules/library/domain/PlayedStudio.java b/src/main/java/core_service/modules/library/domain/PlayedStudio.java new file mode 100644 index 0000000..b2c9057 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PlayedStudio.java @@ -0,0 +1,6 @@ +package core_service.modules.library.domain; + +/** + * A developer of games the user played. + */ +public record PlayedStudio(long companyId, String name) {} diff --git a/src/main/java/core_service/modules/library/repo/GameCreditCheckRepository.java b/src/main/java/core_service/modules/library/repo/GameCreditCheckRepository.java new file mode 100644 index 0000000..87a3c4d --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameCreditCheckRepository.java @@ -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 { + + @Query("select c.appid from GameCreditCheck c where c.appid in :appids and c.checkedAt > :since") + Set findFreshAppids(@Param("appids") Collection 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 findUncheckedAppids(@Param("limit") int limit); +} diff --git a/src/main/java/core_service/modules/library/repo/GameCreditRepository.java b/src/main/java/core_service/modules/library/repo/GameCreditRepository.java new file mode 100644 index 0000000..6de3a0a --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/GameCreditRepository.java @@ -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 { + + @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 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 findPlayedStudios(@Param("userId") Long userId, Pageable pageable); +} diff --git a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java index 6313f52..0d272c5 100644 --- a/src/main/java/core_service/modules/library/repo/GameMergeRepository.java +++ b/src/main/java/core_service/modules/library/repo/GameMergeRepository.java @@ -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 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')"; diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index e0d12e5..4ab37f9 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -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 @Param("excludeAppids") Collection 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 findSeriesGames(Long userId, long seriesId, LocalDate afterDate, Integer maxMinutes, + Collection 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 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 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 findStudioGames(Long userId, long companyId, Integer maxMinutes, + Collection 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 queryStudioGames(@Param("userId") Long userId, + @Param("companyId") long companyId, + @Param("maxMinutes") Integer maxMinutes, + @Param("mood") String mood, + @Param("speedrun") boolean speedrun, + @Param("excludeAppids") Collection excludeAppids, + @Param("count") int count); + /** * The games a user played most, any status, never the ones with no playtime. */ diff --git a/src/main/java/core_service/modules/library/service/GameCreditService.java b/src/main/java/core_service/modules/library/service/GameCreditService.java new file mode 100644 index 0000000..2b26179 --- /dev/null +++ b/src/main/java/core_service/modules/library/service/GameCreditService.java @@ -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. + *

+ * {@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 appids) { + if (!igdbApiService.isEnabled() || appids.isEmpty()) return; + Set stale = stale(appids); + if (stale.isEmpty()) return; + + IgdbLookup.Answer 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 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 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 stale(Collection appids) { + return Refreshes.stale(appids, gameCreditCheckRepository::findFreshAppids, ttlDays); + } +} diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 84efcb2..2cb9c75 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -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. diff --git a/src/main/java/core_service/modules/library/service/GameImportWorker.java b/src/main/java/core_service/modules/library/service/GameImportWorker.java index 996a49d..4224cea 100644 --- a/src/main/java/core_service/modules/library/service/GameImportWorker.java +++ b/src/main/java/core_service/modules/library/service/GameImportWorker.java @@ -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; diff --git a/src/main/java/core_service/modules/library/service/GameMergeService.java b/src/main/java/core_service/modules/library/service/GameMergeService.java index 4990910..86059c3 100644 --- a/src/main/java/core_service/modules/library/service/GameMergeService.java +++ b/src/main/java/core_service/modules/library/service/GameMergeService.java @@ -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; } diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index 5ba93c1..c8913b2 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -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> becauseYouPlayed(Long userId, Integer maxMinutes, List excludeAppids) { @@ -167,13 +173,48 @@ public class LibraryQueryService { Map 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> nextInSeries(Long userId, Integer maxMinutes, + List 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> sameStudio(Long userId, Integer maxMinutes, + List 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 List> onePerAnchor( + List anchors, Function> candidates, Function reason) { Set used = new HashSet<>(); List> 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; } diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 646a328..94fd872 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -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); diff --git a/src/main/java/core_service/modules/library/service/ManualGameService.java b/src/main/java/core_service/modules/library/service/ManualGameService.java index 6223798..056d49d 100644 --- a/src/main/java/core_service/modules/library/service/ManualGameService.java +++ b/src/main/java/core_service/modules/library/service/ManualGameService.java @@ -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; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1cddc4d..2cafc57 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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. diff --git a/src/main/resources/db/migration/V37__Game_Credits.sql b/src/main/resources/db/migration/V37__Game_Credits.sql new file mode 100644 index 0000000..0638401 --- /dev/null +++ b/src/main/resources/db/migration/V37__Game_Credits.sql @@ -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 +); diff --git a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java index 6c6a6fa..a92edc4 100644 --- a/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java +++ b/src/test/java/core_service/modules/igdb_adapter/service/IgdbApiServiceTest.java @@ -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 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(); diff --git a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java index 1749740..e284efb 100644 --- a/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java +++ b/src/test/java/core_service/modules/library/api/GameMergeFlowTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java index 88c2933..698fed1 100644 --- a/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java +++ b/src/test/java/core_service/modules/library/api/LibraryControllerPickerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/repo/GameCreditRepositoryTest.java b/src/test/java/core_service/modules/library/repo/GameCreditRepositoryTest.java new file mode 100644 index 0000000..617ce34 --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/GameCreditRepositoryTest.java @@ -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 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); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameCreditServiceTest.java b/src/test/java/core_service/modules/library/service/GameCreditServiceTest.java new file mode 100644 index 0000000..555f7d7 --- /dev/null +++ b/src/test/java/core_service/modules/library/service/GameCreditServiceTest.java @@ -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()); + } +} diff --git a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java index a0e901f..a1ada29 100644 --- a/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/GameImportWorkerTest.java @@ -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); diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 79376fe..c688854 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -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 diff --git a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java index 400b9ff..4ba062e 100644 --- a/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java +++ b/src/test/java/core_service/modules/library/service/ManualGameServiceTest.java @@ -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 diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index 3b24994..36de2ea 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -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 {