Public Access
Compare commits
2
Commits
5e7455db05
...
b350de1c79
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b350de1c79 | ||
|
|
7663e09a8c |
No files matched your search
@@ -71,18 +71,80 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
|
||||
## Architecture
|
||||
|
||||
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 |
|
||||
|---|---|
|
||||
| `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 |
|
||||
| `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`
|
||||
(Spring Data repositories). ArchUnit enforces cycle freedom, controller-to-repository direction, naming and module
|
||||
isolation in `src/test/java/core_service/architecture/ArchitectureTest.java`.
|
||||
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
||||
|
||||
- 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
|
||||
|
||||
|
||||
@@ -44,6 +44,17 @@ public final class IgdbResponses {
|
||||
@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)
|
||||
public record Image(@JsonProperty("image_id") String imageId) {}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import java.util.Arrays;
|
||||
import java.util.Comparator;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
@@ -39,6 +40,11 @@ public class IgdbApiService {
|
||||
*/
|
||||
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.
|
||||
*
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -240,18 +298,47 @@ public class IgdbApiService {
|
||||
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(","));
|
||||
IgdbResponses.ExternalGame[] externals = query("/external_games",
|
||||
"fields game,uid; where external_game_source = " + STEAM_SOURCE + " & uid = (" + uids + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.ExternalGame[].class);
|
||||
// Editions and re-releases: one IGDB game can have several Steam apps.
|
||||
Map<Long, List<Long>> steamByIgdb = new HashMap<>();
|
||||
for (IgdbResponses.ExternalGame external : externals) {
|
||||
if (external.game() != null && external.uid() != null && external.uid().matches("\\d+")) {
|
||||
steamByIgdb.computeIfAbsent(external.game(), game -> new ArrayList<>()).add(Long.parseLong(external.uid()));
|
||||
}
|
||||
}
|
||||
return steamByIgdb;
|
||||
}
|
||||
|
||||
private Map<Long, TimeToBeat> getBatch(List<Long> steamAppids) {
|
||||
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(steamAppids);
|
||||
if (steamByIgdb.isEmpty()) {
|
||||
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.WishlistDto;
|
||||
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.GameDetailService;
|
||||
import core_service.modules.library.service.LibraryQueryService;
|
||||
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.SyncCooldownException;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
@@ -59,6 +61,7 @@ public class LibraryController {
|
||||
private final GameDetailService gameDetailService;
|
||||
private final BacklogService backlogService;
|
||||
private final SteamProfileService steamProfileService;
|
||||
private final MoodService moodService;
|
||||
|
||||
@GetMapping("/games-count")
|
||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
@@ -178,6 +181,7 @@ public class LibraryController {
|
||||
@GetMapping("/picker")
|
||||
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@RequestParam(required = false) Mood mood,
|
||||
@RequestParam(required = false) List<Long> exclude,
|
||||
@RequestParam(defaultValue = "3") int count) {
|
||||
List<Long> excluded = exclude == null ? List.of()
|
||||
@@ -188,7 +192,10 @@ public class LibraryController {
|
||||
if (principal.steamId() != null) {
|
||||
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")
|
||||
|
||||
@@ -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.GameStatus;
|
||||
import core_service.modules.library.domain.Mood;
|
||||
import core_service.modules.library.domain.StatusCount;
|
||||
import core_service.modules.library.domain.UserGame;
|
||||
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")
|
||||
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);
|
||||
|
||||
@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.
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
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);
|
||||
// "not in ()" is invalid SQL
|
||||
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 " +
|
||||
"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 " +
|
||||
" 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() " +
|
||||
"limit :count", nativeQuery = true)
|
||||
List<UserGame> queryPickerGames(@Param("userId") Long userId,
|
||||
@Param("maxMinutes") Integer maxMinutes,
|
||||
@Param("mood") String mood,
|
||||
@Param("excludeAppids") Collection<Long> excludeAppids,
|
||||
@Param("count") int count);
|
||||
|
||||
|
||||
@@ -43,6 +43,7 @@ public class GameDetailService {
|
||||
private final AchievementSyncService achievementSyncService;
|
||||
private final GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final MoodService moodService;
|
||||
|
||||
@Value("${app.library.achievement-ttl-hours:1}")
|
||||
private long achievementTtlHours;
|
||||
@@ -63,6 +64,7 @@ public class GameDetailService {
|
||||
if (userGame.getSource() == GameSource.MANUAL) {
|
||||
// Users without a Steam ID never sync, so the time to beat of their games is asked again here.
|
||||
timeToBeatService.refreshInBackground(List.of(appid));
|
||||
moodService.refreshInBackground(List.of(appid));
|
||||
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.GameStatus;
|
||||
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.UserGame;
|
||||
import core_service.modules.library.domain.UserGameAchievementMeta;
|
||||
@@ -81,8 +82,8 @@ public class LibraryQueryService {
|
||||
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||
}
|
||||
|
||||
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, List<Long> excludeAppids, int count) {
|
||||
return new PickerDto(toDtos(userGameRepository.findPickerGames(userId, maxMinutes, excludeAppids, count)));
|
||||
public PickerDto pickBacklogGames(Long userId, Integer maxMinutes, Mood mood, List<Long> excludeAppids, int 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 WishlistGameRepository wishlistGameRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final MoodService moodService;
|
||||
private final ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@Value("${app.library.running-stale-minutes:5}")
|
||||
@@ -119,9 +120,10 @@ public class LibrarySyncWorker {
|
||||
}
|
||||
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
||||
// 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());
|
||||
timeToBeatAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
||||
timeToBeatService.refresh(timeToBeatAppids);
|
||||
List<Long> igdbAppids = new ArrayList<>(games.stream().filter(g -> g.appid() != null).map(g -> g.appid().longValue()).toList());
|
||||
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
||||
timeToBeatService.refresh(igdbAppids);
|
||||
moodService.refresh(igdbAppids);
|
||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||
finalStatus = Status.OK;
|
||||
} catch (Exception e) {
|
||||
|
||||
@@ -54,15 +54,17 @@ public class ManualGameService {
|
||||
private final UserGameRepository userGameRepository;
|
||||
private final SteamGameRepository steamGameRepository;
|
||||
private final TimeToBeatService timeToBeatService;
|
||||
private final MoodService moodService;
|
||||
private final TransactionTemplate transaction;
|
||||
|
||||
public ManualGameService(IgdbApiService igdbApiService, UserGameRepository userGameRepository,
|
||||
SteamGameRepository steamGameRepository, TimeToBeatService timeToBeatService,
|
||||
PlatformTransactionManager transactionManager) {
|
||||
MoodService moodService, PlatformTransactionManager transactionManager) {
|
||||
this.igdbApiService = igdbApiService;
|
||||
this.userGameRepository = userGameRepository;
|
||||
this.steamGameRepository = steamGameRepository;
|
||||
this.timeToBeatService = timeToBeatService;
|
||||
this.moodService = moodService;
|
||||
this.transaction = new TransactionTemplate(transactionManager);
|
||||
}
|
||||
|
||||
@@ -141,6 +143,7 @@ public class ManualGameService {
|
||||
}
|
||||
if (result instanceof AddResult.Added) {
|
||||
timeToBeatService.refreshInBackground(List.of(appid));
|
||||
moodService.refreshInBackground(List.of(appid));
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
@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
|
||||
void getTimeToBeat_ReusesTheToken() {
|
||||
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.UserGame;
|
||||
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.SteamGameRepository;
|
||||
import core_service.modules.library.repo.UserGameRepository;
|
||||
import core_service.modules.library.service.LibrarySyncService;
|
||||
import core_service.modules.library.service.MoodService;
|
||||
import core_service.shared.test.AbstractIntegrationTest;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -48,13 +50,20 @@ class LibraryControllerPickerTest extends AbstractIntegrationTest {
|
||||
@Autowired
|
||||
private GameTimeToBeatRepository gameTimeToBeatRepository;
|
||||
|
||||
@Autowired
|
||||
private GameMoodsRepository gameMoodsRepository;
|
||||
|
||||
@MockitoBean
|
||||
private LibrarySyncService syncService;
|
||||
|
||||
@MockitoBean
|
||||
private MoodService moodService;
|
||||
|
||||
@BeforeEach
|
||||
void clean() {
|
||||
userGameRepository.deleteAll();
|
||||
gameTimeToBeatRepository.deleteAll();
|
||||
gameMoodsRepository.deleteAll();
|
||||
}
|
||||
|
||||
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
|
||||
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
|
||||
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());
|
||||
}
|
||||
|
||||
@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
|
||||
void findAchievementProgress_LeavesOutGamesAddedByHand() {
|
||||
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()));
|
||||
|
||||
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
|
||||
|
||||
@@ -67,6 +67,9 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
|
||||
@MockitoBean
|
||||
private TimeToBeatService timeToBeatService;
|
||||
|
||||
@MockitoBean
|
||||
private MoodService moodService;
|
||||
|
||||
@BeforeEach
|
||||
void seed() {
|
||||
userGameRepository.deleteAll();
|
||||
@@ -94,12 +97,13 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAgain() {
|
||||
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
|
||||
userGameRepository.save(UserGame.manual(USER_ID, 620L, "PS5", GameStatus.PLAYING, 60, Instant.now()));
|
||||
|
||||
gameDetailService.getGameDetail(USER_ID, null, 620L);
|
||||
|
||||
org.mockito.Mockito.verify(timeToBeatService).refreshInBackground(List.of(620L));
|
||||
org.mockito.Mockito.verify(moodService).refreshInBackground(List.of(620L));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -103,6 +103,9 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private TimeToBeatService timeToBeatService;
|
||||
|
||||
@Mock
|
||||
private MoodService moodService;
|
||||
|
||||
@Mock
|
||||
private ApplicationEventPublisher eventPublisher;
|
||||
|
||||
@@ -403,12 +406,13 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void runSync_AlsoRefreshesTheTimeToBeatOfTheGamesAddedByHand() {
|
||||
void runSync_AlsoRefreshesTimeToBeatAndMoodsOfTheGamesAddedByHand() {
|
||||
steamReturns(game(0, 0), STEAM_ID);
|
||||
when(userGameRepository.findActiveManualAppids(USER_ID)).thenReturn(List.of(-1025L));
|
||||
|
||||
worker.runSync(USER_ID, STEAM_ID);
|
||||
|
||||
verify(timeToBeatService).refresh(List.of(220L, -1025L));
|
||||
verify(moodService).refresh(List.of(220L, -1025L));
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,9 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
||||
@Mock
|
||||
private TimeToBeatService timeToBeatService;
|
||||
|
||||
@Mock
|
||||
private MoodService moodService;
|
||||
|
||||
@Mock
|
||||
private PlatformTransactionManager transactionManager;
|
||||
|
||||
@@ -49,7 +52,7 @@ class ManualGameServiceTest extends AbstractUnitTest {
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = new ManualGameService(igdbApiService, userGameRepository, steamGameRepository, timeToBeatService,
|
||||
transactionManager);
|
||||
moodService, transactionManager);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user