Compare commits

..
2 Commits
Author SHA1 Message Date
lobo b350de1c79 docs(readme): add the architecture overview
Build, Test & Push Core Service / docker (push) Successful in 1m20s
Build, Test & Push Core Service / quality-check (push) Successful in 53s
2026-09-26 07:37:44 +02:00
lobo 7663e09a8c feat(library): filter the picker by mood
Moods come from IGDB genres, themes and game modes and are stored per game in game_mood (V9), refreshed like the time to beat. Picking a mood also refreshes the moods of the backlog in the background, so users without a Steam sync get them too.
2026-09-26 07:36:10 +02:00
23 changed files with 760 additions and 20 deletions

No files matched your search

+67 -5
View File
@@ -71,18 +71,80 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
## Architecture ## Architecture
A modular monolith. Each module under `core_service.modules` is a vertical slice that could move to its own A modular monolith. Each module under `core_service.modules` is a vertical slice that could move to its own
service later: service later.
```mermaid
flowchart LR
Browser["Frontend in the browser"]
subgraph CoreService["ToBePlayed-Core"]
Security["configuration.security<br/>JwtAuthenticationFilter"]
subgraph Library["library"]
LibApi["api"] --> LibService["service"] --> LibRepo["repo"]
end
Stats["stats"]
Steam["steam_adapter"]
Igdb["igdb_adapter"]
LibService --> Steam
LibService --> Igdb
end
Browser -->|"tbp_access cookie"| Security
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
Steam --> SteamApi["Steam Web API"]
Igdb --> IgdbApi["IGDB API"]
Igdb -->|"OAuth token"| Twitch["Twitch"]
LibRepo --> Db[("PostgreSQL tbp_core")]
Stats --> Db
```
| Module | Content | | Module | Content |
|---|---| |---|---|
| `library` | Sync, backlog, playtime history, wishlist, public profile data | | `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `steam_adapter` | Steam Web API client | | `steam_adapter` | Steam Web API client |
| `igdb_adapter` | IGDB client (Twitch OAuth) | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, game search |
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo` Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
(Spring Data repositories). ArchUnit enforces cycle freedom, controller-to-repository direction, naming and module (repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
isolation in `src/test/java/core_service/architecture/ArchitectureTest.java`.
- Modules do not depend on each other. Only `library` may use `steam_adapter` and `igdb_adapter`.
- No cycles between `modules`, `shared` and `configuration`.
- `api` never uses `repo`, and `repo` never uses `api`.
- Every class in `repo` ends with `Repository`.
- `shared` never uses `modules`, and `modules` never use `configuration`.
- A `*Service` class in a module's `service` package has `@Service`. A `*Controller` class in `api` has
`@RestController`, `@ControllerAdvice` or `@RestControllerAdvice`.
### Sync flow
1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID.
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other.
3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start.
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. When Steam returns no games, the library stays as it is.
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat and mood data, for Steam games
and manual games.
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` in the background.
The frontend polls `GET /api/library/sync-status` until the run ends.
### Manual games next to Steam games
A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games and manual games share
`steam_game` and `user_game` and one key, the column `appid` (see `GameKeys`):
- `appid > 0`: the Steam appid. A manual game gets it when IGDB knows the game on Steam.
- `appid < 0`: minus the IGDB id, for a game that is not on Steam. Steam appids are always positive, so the two
never meet.
`user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
`STEAM`, and status, rating and notes stay. Only `MANUAL` rows can be edited (`PUT /api/library/games/{appid}/manual`)
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
a second row when IGDB learns its Steam appid later and the user owns it on Steam.
## Run locally ## Run locally
@@ -44,6 +44,17 @@ public final class IgdbResponses {
@JsonProperty("total_rating_count") Integer totalRatingCount @JsonProperty("total_rating_count") Integer totalRatingCount
) {} ) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record GameTags(
@JsonProperty("id") Long id,
@JsonProperty("genres") java.util.List<Named> genres,
@JsonProperty("themes") java.util.List<Named> themes,
@JsonProperty("game_modes") java.util.List<Named> gameModes
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Named(@JsonProperty("name") String name) {}
@JsonIgnoreProperties(ignoreUnknown = true) @JsonIgnoreProperties(ignoreUnknown = true)
public record Image(@JsonProperty("image_id") String imageId) {} public record Image(@JsonProperty("image_id") String imageId) {}
@@ -20,6 +20,7 @@ import java.util.Arrays;
import java.util.Comparator; import java.util.Comparator;
import java.util.Collection; import java.util.Collection;
import java.util.HashMap; import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
@@ -39,6 +40,11 @@ public class IgdbApiService {
*/ */
public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {} public record TimeToBeat(Integer hastilyMinutes, Integer normallyMinutes, Integer completelyMinutes) {}
/**
* IGDB's genre, theme and game mode names of one game, e.g. "Role-playing (RPG)", "Horror", "Co-operative".
*/
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
/** /**
* One IGDB game as the library needs it. * One IGDB game as the library needs it.
* *
@@ -115,6 +121,58 @@ public class IgdbApiService {
} }
} }
/**
* @return the tags of the Steam apps IGDB links to a game, empty lists when that game has no tags; apps IGDB does
* not link are missing. An app linked to several games (say a remaster) gets the tags of all of them. Empty when
* the adapter is off or a request failed.
*/
public Optional<Map<Long, GameTags>> getGameTags(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, GameTags> 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())));
tagsOf(steamByIgdb.keySet()).forEach((igdbId, tags) ->
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, tags, IgdbApiService::union)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for game tags", e);
return Optional.empty();
}
}
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()));
}
private static List<String> union(List<String> a, List<String> b) {
return List.copyOf(new LinkedHashSet<>(Stream.concat(a.stream(), b.stream()).toList()));
}
/**
* Like {@link #getGameTags}, but for IGDB game ids.
*/
public Optional<Map<Long, GameTags>> getGameTagsByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, GameTags> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(tagsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for game tags", e);
return Optional.empty();
}
}
/** /**
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed. * Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
*/ */
@@ -240,18 +298,47 @@ public class IgdbApiService {
return text.replaceAll("[\"\\\\;\\p{Cntrl}]", " ").trim(); return text.replaceAll("[\"\\\\;\\p{Cntrl}]", " ").trim();
} }
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) { private Map<Long, GameTags> tagsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.GameTags[] games = query("/games",
"fields genres.name,themes.name,game_modes.name; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.GameTags[].class);
Map<Long, GameTags> result = new HashMap<>();
for (IgdbResponses.GameTags game : games) {
if (game.id() != null) {
result.put(game.id(), new GameTags(names(game.genres()), names(game.themes()), names(game.gameModes())));
}
}
return result;
}
private static List<String> names(List<IgdbResponses.Named> named) {
return named == null ? List.of() : named.stream()
.map(IgdbResponses.Named::name)
.filter(name -> name != null && !name.isBlank())
.toList();
}
// Editions and re-releases: one IGDB game can have several Steam apps.
private Map<Long, List<Long>> steamAppidsByIgdbGame(List<Long> steamAppids) {
String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(",")); String uids = steamAppids.stream().map(id -> "\"" + id + "\"").collect(Collectors.joining(","));
IgdbResponses.ExternalGame[] externals = query("/external_games", IgdbResponses.ExternalGame[] externals = query("/external_games",
"fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";", "fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";",
IgdbResponses.ExternalGame[].class); IgdbResponses.ExternalGame[].class);
// Editions and re-releases: one IGDB game can have several Steam apps.
Map<Long, List<Long>> steamByIgdb = new HashMap<>(); Map<Long, List<Long>> steamByIgdb = new HashMap<>();
for (IgdbResponses.ExternalGame external : externals) { for (IgdbResponses.ExternalGame external : externals) {
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) { if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid())); steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
} }
} }
return steamByIgdb;
}
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(steamAppids);
if (steamByIgdb.isEmpty()) { if (steamByIgdb.isEmpty()) {
return Map.of(); return Map.of();
} }
@@ -12,10 +12,12 @@ import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto; import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.WishlistDto; import core_service.modules.library.api.dto.WishlistDto;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.service.BacklogService; import core_service.modules.library.service.BacklogService;
import core_service.modules.library.service.GameDetailService; import core_service.modules.library.service.GameDetailService;
import core_service.modules.library.service.LibraryQueryService; import core_service.modules.library.service.LibraryQueryService;
import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.modules.library.service.SteamProfileService; import core_service.modules.library.service.SteamProfileService;
import core_service.modules.library.service.SyncCooldownException; import core_service.modules.library.service.SyncCooldownException;
import core_service.shared.security.UserPrincipal; import core_service.shared.security.UserPrincipal;
@@ -59,6 +61,7 @@ public class LibraryController {
private final GameDetailService gameDetailService; private final GameDetailService gameDetailService;
private final BacklogService backlogService; private final BacklogService backlogService;
private final SteamProfileService steamProfileService; private final SteamProfileService steamProfileService;
private final MoodService moodService;
@GetMapping("/games-count") @GetMapping("/games-count")
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) { public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
@@ -178,6 +181,7 @@ public class LibraryController {
@GetMapping("/picker") @GetMapping("/picker")
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal, public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Integer maxMinutes, @RequestParam(required = false) Integer maxMinutes,
@RequestParam(required = false) Mood mood,
@RequestParam(required = false) List<Long> exclude, @RequestParam(required = false) List<Long> exclude,
@RequestParam(defaultValue = "3") int count) { @RequestParam(defaultValue = "3") int count) {
List<Long> excluded = exclude == null ? List.of() List<Long> excluded = exclude == null ? List.of()
@@ -188,7 +192,10 @@ public class LibraryController {
if (principal.steamId() != null) { if (principal.steamId() != null) {
syncService.syncIfStale(userId, String.valueOf(principal.steamId())); syncService.syncIfStale(userId, String.valueOf(principal.steamId()));
} }
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, excluded, safeCount)); if (mood != null) {
moodService.refreshBacklogInBackground(userId);
}
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
} }
@GetMapping("/sync-status") @GetMapping("/sync-status")
@@ -0,0 +1,35 @@
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 org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.Instant;
import java.util.List;
/**
* The moods of one game, see {@link Mood}.
*/
@Entity
@Table(name = "game_mood")
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class GameMoods {
@Id
@Column(name = "appid")
private Long appid;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "moods", nullable = false)
private List<String> moods;
@Column(name = "checked_at", nullable = false)
private Instant checkedAt;
}
@@ -0,0 +1,48 @@
package core_service.modules.library.domain;
import java.util.Collection;
import java.util.EnumSet;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* What the user feels like playing, for the picker. Derived from IGDB's genre, theme and game mode names; a game can
* have several moods or none.
*/
public enum Mood {
STORY, COOP, CHILL, ACTION, SCARY;
private static final Set<String> STORY_GENRES = Set.of("role-playing (rpg)", "visual novel");
private static final Set<String> STORY_THEMES = Set.of("drama");
private static final Set<String> COOP_MODES = Set.of("co-operative", "split screen");
private static final Set<String> CHILL_GENRES = Set.of("puzzle", "simulator", "card & board game", "point-and-click");
private static final Set<String> CHILL_THEMES = Set.of("sandbox");
private static final Set<String> ACTION_GENRES = Set.of("shooter", "fighting", "hack and slash/beat 'em up", "platform");
private static final Set<String> ACTION_THEMES = Set.of("action");
private static final Set<String> SCARY_THEMES = Set.of("horror");
public static Set<Mood> of(Collection<String> genres, Collection<String> themes, Collection<String> modes) {
Set<String> genre = lowerCase(genres);
Set<String> theme = lowerCase(themes);
Set<String> mode = lowerCase(modes);
Set<Mood> moods = EnumSet.noneOf(Mood.class);
// IGDB puts "Adventure" on most action games too (GTA V, Uncharted); alone it means a story game.
boolean storyAdventure = genre.contains("adventure") && !anyIn(genre, ACTION_GENRES);
if (storyAdventure || anyIn(genre, STORY_GENRES) || anyIn(theme, STORY_THEMES)) moods.add(STORY);
if (anyIn(mode, COOP_MODES)) moods.add(COOP);
// A horror puzzle game is no way to relax.
if ((anyIn(genre, CHILL_GENRES) || anyIn(theme, CHILL_THEMES)) && !theme.contains("horror")) moods.add(CHILL);
if (anyIn(genre, ACTION_GENRES) || anyIn(theme, ACTION_THEMES)) moods.add(ACTION);
if (anyIn(theme, SCARY_THEMES)) moods.add(SCARY);
return moods;
}
private static Set<String> lowerCase(Collection<String> names) {
return names.stream().map(name -> name.trim().toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
}
private static boolean anyIn(Set<String> names, Set<String> wanted) {
return names.stream().anyMatch(wanted::contains);
}
}
@@ -0,0 +1,29 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.GameMoods;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.Collection;
import java.util.Set;
public interface GameMoodsRepository extends JpaRepository<GameMoods, Long> {
@Query("select m.appid from GameMoods m where m.appid in :appids and m.checkedAt > :since")
Set<Long> findFreshAppids(@Param("appids") Collection<Long> appids, @Param("since") Instant since);
/**
* @param moods {@link core_service.modules.library.domain.Mood} names; empty when none fits
*/
// An upsert, not save(): two users' syncs can store the same new game at the same time.
@Modifying
@Transactional
@Query(value = "insert into game_mood (appid, moods, checked_at) values (:appid, cast(:moods as varchar(16)[]), :checkedAt) " +
"on conflict (appid) do update set moods = excluded.moods, checked_at = excluded.checked_at",
nativeQuery = true)
void upsert(@Param("appid") Long appid, @Param("moods") String[] moods, @Param("checkedAt") Instant checkedAt);
}
@@ -2,6 +2,7 @@ package core_service.modules.library.repo;
import core_service.modules.library.domain.AchievementProgress; import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.StatusCount; import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
@@ -37,6 +38,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and ug.source = core_service.modules.library.domain.GameSource.MANUAL") "and ug.source = core_service.modules.library.domain.GameSource.MANUAL")
List<Long> findActiveManualAppids(@Param("userId") Long userId); List<Long> findActiveManualAppids(@Param("userId") Long userId);
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null " +
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
List<Long> findBacklogAppids(@Param("userId") Long userId);
long countByUserIdAndRemovedAtIsNull(Long userId); long countByUserIdAndRemovedAtIsNull(Long userId);
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null") @Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
@@ -81,23 +86,27 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
* Random backlog games, HIGH priority first, then MEDIUM, then the rest. * Random backlog games, HIGH priority first, then MEDIUM, then the rest.
* *
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game * @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
* @param mood only games known to have this mood; null for every game
* @param excludeAppids never picked; may be empty * @param excludeAppids never picked; may be empty
*/ */
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) { default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Mood mood, Collection<Long> excludeAppids, int count) {
List<Long> excluded = new ArrayList<>(excludeAppids); List<Long> excluded = new ArrayList<>(excludeAppids);
// "not in ()" is invalid SQL // "not in ()" is invalid SQL
excluded.add(NO_GAME); excluded.add(NO_GAME);
return queryPickerGames(userId, maxMinutes, excluded, count); return queryPickerGames(userId, maxMinutes, mood == null ? null : mood.name(), excluded, count);
} }
@Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " + @Query(value = "select ug.* from user_game ug where ug.user_id = :userId and ug.removed_at is null " +
"and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " + "and ug.status = 'BACKLOG' and ug.appid not in (:excludeAppids) " +
"and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " + "and (cast(:maxMinutes as integer) is null or exists (select 1 from game_time_to_beat t " +
" where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " + " where t.appid = ug.appid and t.normally_minutes <= :maxMinutes)) " +
"and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " +
" where m.appid = ug.appid and cast(:mood as varchar) = any(m.moods))) " +
"order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " + "order by case ug.priority when 'HIGH' then 0 when 'MEDIUM' then 1 else 2 end, random() " +
"limit :count", nativeQuery = true) "limit :count", nativeQuery = true)
List<UserGame> queryPickerGames(@Param("userId") Long userId, List<UserGame> queryPickerGames(@Param("userId") Long userId,
@Param("maxMinutes") Integer maxMinutes, @Param("maxMinutes") Integer maxMinutes,
@Param("mood") String mood,
@Param("excludeAppids") Collection<Long> excludeAppids, @Param("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count); @Param("count") int count);
@@ -43,6 +43,7 @@ public class GameDetailService {
private final AchievementSyncService achievementSyncService; private final AchievementSyncService achievementSyncService;
private final GameTimeToBeatRepository gameTimeToBeatRepository; private final GameTimeToBeatRepository gameTimeToBeatRepository;
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
@Value("${app.library.achievement-ttl-hours:1}") @Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours; private long achievementTtlHours;
@@ -63,6 +64,7 @@ public class GameDetailService {
if (userGame.getSource() == GameSource.MANUAL) { if (userGame.getSource() == GameSource.MANUAL) {
// Users without a Steam ID never sync, so the time to beat of their games is asked again here. // Users without a Steam ID never sync, so the time to beat of their games is asked again here.
timeToBeatService.refreshInBackground(List.of(appid)); timeToBeatService.refreshInBackground(List.of(appid));
moodService.refreshInBackground(List.of(appid));
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of())); return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
} }
@@ -14,6 +14,7 @@ import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.GameSource; import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat; import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta; import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -81,8 +82,8 @@ public class LibraryQueryService {
return new PageImpl<>(games, page.getPageable(), page.getTotalElements()); return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
} }
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List<Long> excludeAppids, int count) { public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int count) {
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count))); return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, mood, excludeAppids, count)));
} }
/** /**
@@ -51,6 +51,7 @@ public class LibrarySyncWorker {
private final PlaytimeDayRepository playtimeDayRepository; private final PlaytimeDayRepository playtimeDayRepository;
private final WishlistGameRepository wishlistGameRepository; private final WishlistGameRepository wishlistGameRepository;
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final ApplicationEventPublisher eventPublisher; private final ApplicationEventPublisher eventPublisher;
@Value("${app.library.running-stale-minutes:5}") @Value("${app.library.running-stale-minutes:5}")
@@ -119,9 +120,10 @@ public class LibrarySyncWorker {
} }
userSteamSyncRepository.markHistoryBaseline(userId, steamId); userSteamSyncRepository.markHistoryBaseline(userId, steamId);
// Also the games added by hand: here a failed refresh from the add is asked again. // Also the games added by hand: here a failed refresh from the add is asked again.
List<Long> timeToBeatAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList()); List<Long> igdbAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
timeToBeatAppids.addAll(userGameRepository.findActiveManualAppids(userId)); igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
timeToBeatService.refresh(timeToBeatAppids); timeToBeatService.refresh(igdbAppids);
moodService.refresh(igdbAppids);
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId)); eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
finalStatus = Status.OK; finalStatus = Status.OK;
} catch (Exception e) { } catch (Exception e) {
@@ -54,15 +54,17 @@ public class ManualGameService {
private final UserGameRepository userGameRepository; private final UserGameRepository userGameRepository;
private final SteamGameRepository steamGameRepository; private final SteamGameRepository steamGameRepository;
private final TimeToBeatService timeToBeatService; private final TimeToBeatService timeToBeatService;
private final MoodService moodService;
private final TransactionTemplate transaction; private final TransactionTemplate transaction;
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository, public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService, SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService,
PlatformTransactionManager transactionManager) { MoodService moodService, PlatformTransactionManager transactionManager) {
this.igdbApiService = igdbApiService; this.igdbApiService = igdbApiService;
this.userGameRepository = userGameRepository; this.userGameRepository = userGameRepository;
this.steamGameRepository = steamGameRepository; this.steamGameRepository = steamGameRepository;
this.timeToBeatService = timeToBeatService; this.timeToBeatService = timeToBeatService;
this.moodService = moodService;
this.transaction = new TransactionTemplate(transactionManager); this.transaction = new TransactionTemplate(transactionManager);
} }
@@ -141,6 +143,7 @@ public class ManualGameService {
} }
if (result instanceof AddResult.Added) { if (result instanceof AddResult.Added) {
timeToBeatService.refreshInBackground(List.of(appid)); timeToBeatService.refreshInBackground(List.of(appid));
moodService.refreshInBackground(List.of(appid));
} }
return result; return result;
} }
@@ -0,0 +1,102 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.domain.GameKeys;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.UserGameRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.time.Instant;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.TreeSet;
/**
* Stores the {@link Mood}s of games from IGDB's genres, themes and game modes, the same way
* {@link TimeToBeatService} stores the time to beat: only for games without a fresh row, and nothing when IGDB fails.
*/
@Service
@RequiredArgsConstructor
@Slf4j
public class MoodService {
private final IgdbApiService igdbApiService;
private final GameMoodsRepository gameMoodsRepository;
private final UserGameRepository userGameRepository;
@Value("${app.library.mood-ttl-days:30}")
private long ttlDays;
public void refresh(Collection<Long> appids) {
if (!igdbApiService.isEnabled() || appids.isEmpty()) return;
// Sorted: parallel syncs then lock the upserted rows in the same order and cannot deadlock.
Set<Long> stale = new TreeSet<>(appids);
stale.removeAll(gameMoodsRepository.findFreshAppids(appids, Instant.now().minus(Duration.ofDays(ttlDays))));
if (stale.isEmpty()) return;
Set<Long> steamAppids = new TreeSet<>();
Set<Long> igdbIds = new TreeSet<>();
for (Long appid : stale) {
if (GameKeys.isSteam(appid)) {
steamAppids.add(appid);
} else {
igdbIds.add(GameKeys.igdbId(appid));
}
}
Map<Long, IgdbApiService.GameTags> tags = new HashMap<>();
Set<Long> answered = new TreeSet<>();
if (!steamAppids.isEmpty()) {
igdbApiService.getGameTags(steamAppids).ifPresent(found -> {
tags.putAll(found);
answered.addAll(steamAppids);
});
}
if (!igdbIds.isEmpty()) {
igdbApiService.getGameTagsByGameIds(igdbIds).ifPresent(found -> {
found.forEach((igdbId, gameTags) -> tags.put(GameKeys.ofIgdb(igdbId), gameTags));
igdbIds.forEach(igdbId -> answered.add(GameKeys.ofIgdb(igdbId)));
});
}
if (answered.size() < stale.size()) {
log.warn("Moods of {} games not refreshed, IGDB did not answer", stale.size() - answered.size());
}
Instant now = Instant.now();
for (Long appid : answered) {
IgdbApiService.GameTags gameTags = tags.get(appid);
Set<Mood> moods = gameTags == null ? Set.of() : Mood.of(gameTags.genres(), gameTags.themes(), gameTags.modes());
gameMoodsRepository.upsert(appid, moods.stream().map(Mood::name).sorted().toArray(String[]::new), now);
}
}
/**
* Same as {@link #refresh}, but the request does not wait for IGDB, and an error does not fail it.
*/
@Async
public void refreshInBackground(Collection<Long> appids) {
try {
refresh(appids);
} catch (Exception e) {
log.warn("Moods of {} not refreshed: {}", appids, e.getMessage());
}
}
/**
* For the picker: a user without a Steam sync gets moods only here, and a Steam user before the next sync.
*/
@Async
public void refreshBacklogInBackground(Long userId) {
try {
refresh(userGameRepository.findBacklogAppids(userId));
} catch (Exception e) {
log.warn("Backlog moods of user {} not refreshed: {}", userId, e.getMessage());
}
}
}
@@ -0,0 +1,8 @@
-- Moods per library key (Steam appid, or minus the IGDB id), derived from IGDB's genres, themes and game modes and
-- shared by all users. An empty array means IGDB knows the game but no mood fits; the row is kept so the game is not
-- asked again before it is stale.
CREATE TABLE game_mood (
appid BIGINT PRIMARY KEY,
moods VARCHAR(16)[] NOT NULL,
checked_at TIMESTAMPTZ NOT NULL
);
@@ -87,6 +87,76 @@ class IgdbApiServiceTest extends AbstractUnitTest {
assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350)); assertThat(result.get().get(620L)).isEqualTo(new IgdbApiService.TimeToBeat(510, 750, 1350));
} }
@Test
void getGameTags_MapsSteamAppsToTheNamesOfTheirIgdbGame() {
expectToken();
igdb.expect(requestTo(containsString("/external_games")))
.andExpect(content().string(allOf(containsString("external_game_source = 1"), containsString("uid = (\"620\",\"220\")"))))
.andRespond(withSuccess("[{\"id\": 1, \"game\": 72, \"uid\": \"620\"}, {\"id\": 2, \"game\": 233, \"uid\": \"220\"}]",
MediaType.APPLICATION_JSON));
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(allOf(containsString("genres.name,themes.name,game_modes.name"),
containsString("id = (72,233)"))))
.andRespond(withSuccess("[{\"id\": 72, \"genres\": [{\"id\": 9, \"name\": \"Puzzle\"}],"
+ " \"themes\": [{\"id\": 1, \"name\": \"Action\"}], \"game_modes\": [{\"id\": 3, \"name\": \"Co-operative\"}]},"
+ " {\"id\": 233}]", MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.GameTags> tags = service.getGameTags(List.of(620L, 220L)).orElseThrow();
assertThat(tags).containsOnly(
Map.entry(620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of("Action"), List.of("Co-operative"))),
Map.entry(220L, new IgdbApiService.GameTags(List.of(), List.of(), List.of())));
}
// REVIEW-FINDING: one Steam app linked to two IGDB games (main entry + edition) keeps the tags of whichever game
// the HashMap yields first and drops the other's. Here game 72 has no tags and game 233 is Horror; 72 sits in the
// earlier bucket, so 620 is stored with no mood. Time to beat picks the most-voted entry; tags should be the union.
@Test
void getGameTags_SteamAppLinkedToTwoIgdbGames_KeepsTheTagsOfBoth() {
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}, {\"id\": 233, \"themes\": [{\"id\": 19, \"name\": \"Horror\"}]}]",
MediaType.APPLICATION_JSON));
Map<Long, IgdbApiService.GameTags> tags = service.getGameTags(List.of(620L)).orElseThrow();
assertThat(tags.get(620L).themes()).contains("Horror");
}
@Test
void getGameTags_NoSteamAppKnown_AsksNoGames() {
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withSuccess("[]", MediaType.APPLICATION_JSON));
assertThat(service.getGameTags(List.of(620L))).contains(Map.of());
igdb.verify();
}
@Test
void getGameTagsByGameIds_AsksTheGamesDirectly() {
expectToken();
igdb.expect(requestTo(containsString("/games")))
.andExpect(content().string(containsString("id = (119388)")))
.andRespond(withSuccess("[{\"id\": 119388, \"genres\": [{\"name\": \"Adventure\"}]}]", MediaType.APPLICATION_JSON));
assertThat(service.getGameTagsByGameIds(List.of(119388L))).contains(Map.of(119388L,
new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of())));
}
@Test
void getGameTags_OffOrError_IsEmpty() {
assertThat(create("", "").getGameTags(List.of(620L))).isEmpty();
service = create("client-1", "secret-1");
expectToken();
igdb.expect(requestTo(containsString("/external_games"))).andRespond(withServerError());
assertThat(service.getGameTags(List.of(620L))).isEmpty();
}
@Test @Test
void getTimeToBeat_ReusesTheToken() { void getTimeToBeat_ReusesTheToken() {
expectToken(); expectToken();
@@ -6,10 +6,12 @@ import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId; import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository; import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.library.service.LibrarySyncService; import core_service.modules.library.service.LibrarySyncService;
import core_service.modules.library.service.MoodService;
import core_service.shared.test.AbstractIntegrationTest; import core_service.shared.test.AbstractIntegrationTest;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
@@ -48,13 +50,20 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
@Autowired @Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository; private GameTimeToBeatRepository gameTimeToBeatRepository;
@Autowired
private GameMoodsRepository gameMoodsRepository;
@MockitoBean @MockitoBean
private LibrarySyncService syncService; private LibrarySyncService syncService;
@MockitoBean
private MoodService moodService;
@BeforeEach @BeforeEach
void clean() { void clean() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll(); gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
} }
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) { private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
@@ -68,6 +77,41 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
} }
} }
@Test
void picker_WithMood_PicksOnlyGamesKnownToHaveIt() throws Exception {
game(10L, GameStatus.BACKLOG, null, null);
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.BACKLOG, null, null);
game(40L, GameStatus.FINISHED, null, null);
gameMoodsRepository.upsert(10L, new String[]{"CHILL", "COOP"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"ACTION"}, Instant.now());
gameMoodsRepository.upsert(40L, new String[]{"CHILL"}, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "CHILL"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20, 30)));
verify(moodService).refreshBacklogInBackground(USER_ID);
}
@Test
void picker_MoodAndBudget_BothMustFit() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 3000);
gameMoodsRepository.upsert(10L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(20L, new String[]{"STORY"}, Instant.now());
mockMvc.perform(get("/api/library/picker").param("mood", "STORY").param("maxMinutes", "900"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_UnknownMood_Is400() throws Exception {
mockMvc.perform(get("/api/library/picker").param("mood", "HARD"))
.andExpect(status().isBadRequest());
}
@Test @Test
void picker_EmptyBacklog_ReturnsNoGames() throws Exception { void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
game(10L, GameStatus.PLAYING, null, 60); game(10L, GameStatus.PLAYING, null, 60);
@@ -0,0 +1,48 @@
package core_service.modules.library.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class MoodTest extends AbstractUnitTest {
@Test
void of_MapsIgdbNamesIgnoringCase() {
assertThat(Mood.of(List.of("Role-playing (RPG)"), List.of(), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of(), List.of("drama"), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of(), List.of(), List.of("Split screen"))).containsExactly(Mood.COOP);
assertThat(Mood.of(List.of("Card & Board Game"), List.of(), List.of())).containsExactly(Mood.CHILL);
assertThat(Mood.of(List.of("Hack and slash/Beat 'em up"), List.of(), List.of())).containsExactly(Mood.ACTION);
assertThat(Mood.of(List.of(), List.of("Horror"), List.of())).containsExactly(Mood.SCARY);
}
@Test
void of_AGameCanHaveSeveralMoods() {
assertThat(Mood.of(List.of("Shooter", "Role-playing (RPG)"), List.of("Horror"), List.of("Single player", "Co-operative")))
.containsExactlyInAnyOrder(Mood.STORY, Mood.COOP, Mood.ACTION, Mood.SCARY);
}
@Test
void of_Adventure_IsAStoryOnlyWithoutAnActionGenre() {
assertThat(Mood.of(List.of("Adventure"), List.of(), List.of())).containsExactly(Mood.STORY);
assertThat(Mood.of(List.of("Adventure", "Shooter"), List.of(), List.of())).containsExactly(Mood.ACTION);
}
@Test
void of_SurvivalWithoutHorror_IsNotScary() {
assertThat(Mood.of(List.of(), List.of("Survival", "Sandbox"), List.of())).containsExactly(Mood.CHILL);
}
@Test
void of_AHorrorGameIsNeverChill() {
assertThat(Mood.of(List.of("Puzzle"), List.of("Horror"), List.of())).containsExactly(Mood.SCARY);
}
@Test
void of_NothingThatFits_HasNoMood() {
assertThat(Mood.of(List.of("Racing", "Sport"), List.of("Science fiction"), List.of("Multiplayer"))).isEmpty();
}
}
@@ -0,0 +1,48 @@
package core_service.modules.library.repo;
import core_service.shared.test.AbstractPersistenceTest;
import jakarta.persistence.EntityManager;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class GameMoodsRepositoryTest extends AbstractPersistenceTest {
@Autowired
private GameMoodsRepository gameMoodsRepository;
@Autowired
private EntityManager entityManager;
@BeforeEach
void clean() {
gameMoodsRepository.deleteAll();
}
@Test
void upsert_StoresTheMoodsAndReplacesThemOnTheNextRefresh() {
gameMoodsRepository.upsert(620L, new String[]{"CHILL", "COOP"}, Instant.now());
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).containsExactly("CHILL", "COOP");
gameMoodsRepository.upsert(620L, new String[0], Instant.now());
// The native upsert passes the persistence context; read the row again.
entityManager.clear();
assertThat(gameMoodsRepository.findById(620L).orElseThrow().getMoods()).isEmpty();
}
@Test
void findFreshAppids_LeavesOutOldAndMissingRows() {
gameMoodsRepository.upsert(620L, new String[]{"STORY"}, Instant.now());
gameMoodsRepository.upsert(220L, new String[]{"ACTION"}, Instant.now().minus(Duration.ofDays(40)));
assertThat(gameMoodsRepository.findFreshAppids(List.of(620L, 220L, 700L), Instant.now().minus(Duration.ofDays(30))))
.isEqualTo(Set.of(620L));
}
}
@@ -80,6 +80,15 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
assertEquals(GameSource.MANUAL, manual.getSource()); assertEquals(GameSource.MANUAL, manual.getSource());
} }
@Test
void findBacklogAppids_OnlyActiveBacklogGamesOfTheUser() {
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setStatus(GameStatus.BACKLOG);
userGameRepository.findById(new UserGameId(USER_ID, 700L)).orElseThrow().setRemovedAt(Instant.now());
userGameRepository.flush();
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findBacklogAppids(USER_ID)));
}
@Test @Test
void findAchievementProgress_LeavesOutGamesAddedByHand() { void findAchievementProgress_LeavesOutGamesAddedByHand() {
steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now())); steamGameRepository.save(new SteamGame(999L, "Elden Ring", null, Instant.now()));
@@ -95,7 +104,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, -1L, "PS2", GameStatus.BACKLOG, 0, Instant.now()));
assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid()); assertEquals(-1L, userGameRepository.findRandomBacklogGame(USER_ID, UserGameRepository.NO_GAME).orElseThrow().getAppid());
assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, List.of(), 3).size()); assertEquals(1, userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 3).size());
} }
@Test @Test
@@ -67,6 +67,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
@MockitoBean @MockitoBean
private TimeToBeatService timeToBeatService; private TimeToBeatService timeToBeatService;
@MockitoBean
private MoodService moodService;
@BeforeEach @BeforeEach
void seed() { void seed() {
userGameRepository.deleteAll(); userGameRepository.deleteAll();
@@ -94,12 +97,13 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
} }
@Test @Test
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() { void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now())); userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
gameDetailService.getGameDetail(USER_ID, null, 620L); gameDetailService.getGameDetail(USER_ID, null, 620L);
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L)); org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L));
org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L));
} }
@Test @Test
@@ -103,6 +103,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
@Mock @Mock
private TimeToBeatService timeToBeatService; private TimeToBeatService timeToBeatService;
@Mock
private MoodService moodService;
@Mock @Mock
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
@@ -403,12 +406,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
} }
@Test @Test
void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() { void runSync_AlsoRefreshesTimeToBeatAndMoodsOfTheGamesAddedByHand() {
steamReturns(game(0, 0), STEAM_ID); steamReturns(game(0, 0), STEAM_ID);
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L)); when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
worker.runSync(USER_ID, STEAM_ID); worker.runSync(USER_ID, STEAM_ID);
verify(timeToBeatService).refresh(List.of(220L, -1025L)); verify(timeToBeatService).refresh(List.of(220L, -1025L));
verify(moodService).refresh(List.of(220L, -1025L));
} }
} }
@@ -41,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
@Mock @Mock
private TimeToBeatService timeToBeatService; private TimeToBeatService timeToBeatService;
@Mock
private MoodService moodService;
@Mock @Mock
private PlatformTransactionManager transactionManager; private PlatformTransactionManager transactionManager;
@@ -49,7 +52,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
@BeforeEach @BeforeEach
void setUp() { void setUp() {
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService, service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
transactionManager); moodService, transactionManager);
} }
@Test @Test
@@ -0,0 +1,104 @@
package core_service.modules.library.service;
import core_service.modules.igdb_adapter.service.IgdbApiService;
import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.springframework.test.util.ReflectionTestUtils;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import static org.mockito.AdditionalMatchers.aryEq;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyCollection;
import static org.mockito.ArgumentMatchers.eq;
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 MoodServiceTest extends AbstractUnitTest {
@Mock
private IgdbApiService igdbApiService;
@Mock
private GameMoodsRepository gameMoodsRepository;
@Mock
private UserGameRepository userGameRepository;
@InjectMocks
private MoodService service;
@BeforeEach
void setUp() {
ReflectionTestUtils.setField(service, "ttlDays", 30L);
}
@Test
void refresh_StoresTheMoodsOfStaleGamesAndAnEmptyListForGamesWithoutData() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of(220L));
when(igdbApiService.getGameTags(Set.of(620L, 700L))).thenReturn(Optional.of(Map.of(
620L, new IgdbApiService.GameTags(List.of("Puzzle"), List.of(), List.of("Co-operative")))));
service.refresh(List.of(220L, 620L, 700L));
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[]{"CHILL", "COOP"}), any(Instant.class));
verify(gameMoodsRepository).upsert(eq(700L), aryEq(new String[0]), any(Instant.class));
verify(gameMoodsRepository, never()).upsert(eq(220L), any(), any());
}
@Test
void refreshBacklogInBackground_RefreshesTheBacklogOfTheUser() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(userGameRepository.findBacklogAppids(7L)).thenReturn(List.of(620L));
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(Set.of(620L))).thenReturn(Optional.of(Map.of()));
service.refreshBacklogInBackground(7L);
verify(gameMoodsRepository).upsert(eq(620L), aryEq(new String[0]), any(Instant.class));
}
@Test
void refresh_GamesOffSteam_AreAskedByIgdbIdAndStoredUnderTheirKey() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTagsByGameIds(Set.of(119388L))).thenReturn(Optional.of(Map.of(
119388L, new IgdbApiService.GameTags(List.of("Adventure"), List.of(), List.of()))));
service.refresh(List.of(-119388L));
verify(gameMoodsRepository).upsert(eq(-119388L), aryEq(new String[]{"STORY"}), any(Instant.class));
}
@Test
void refresh_IgdbFailed_StoresNothingSoTheGamesAreAskedAgain() {
when(igdbApiService.isEnabled()).thenReturn(true);
when(gameMoodsRepository.findFreshAppids(anyCollection(), any())).thenReturn(Set.of());
when(igdbApiService.getGameTags(any())).thenReturn(Optional.empty());
service.refresh(List.of(620L));
verify(gameMoodsRepository, never()).upsert(any(), any(), any());
}
@Test
void refresh_IgdbOff_DoesNothing() {
when(igdbApiService.isEnabled()).thenReturn(false);
service.refresh(List.of(620L));
verifyNoInteractions(gameMoodsRepository);
}
}