Public Access
feat(library): hide games from lists and stats
This commit is contained in:
1 parent
246e255058
commit
c2d17d0bc7
27 files changed
+559
-56
No files matched your search
@@ -31,6 +31,10 @@ To Be Played is split into three repositories:
|
|||||||
games closest to 100 %.
|
games closest to 100 %.
|
||||||
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
||||||
dashboard banner.
|
dashboard banner.
|
||||||
|
- **Hide games**: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library,
|
||||||
|
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
|
||||||
|
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
|
||||||
|
lists hidden games with `hidden=true`.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
- **Broken image repair**: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
|
||||||
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
|
||||||
@@ -61,6 +65,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
|
|||||||
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
| `GET` | `/api/library/games`, `/api/library/games/{appid}` | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
| `PUT` | `/api/library/games/{appid}/backlog` | JWT |
|
||||||
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||||
|
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT |
|
||||||
| `POST` | `/api/library/sync` | JWT |
|
| `POST` | `/api/library/sync` | JWT |
|
||||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
|
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
|
||||||
@@ -143,7 +148,7 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
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.
|
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
|
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat and mood data, for Steam games
|
||||||
and manual games.
|
and manual games. Hidden games only get their playtime updated.
|
||||||
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
|
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
|
||||||
in the background.
|
in the background.
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.ActivityDto;
|
|||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
import core_service.modules.library.api.dto.BrokenImageReport;
|
import core_service.modules.library.api.dto.BrokenImageReport;
|
||||||
import core_service.modules.library.api.dto.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
|
import core_service.modules.library.api.dto.HiddenUpdateDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
import core_service.modules.library.api.dto.PageDto;
|
import core_service.modules.library.api.dto.PageDto;
|
||||||
import core_service.modules.library.api.dto.PickerDto;
|
import core_service.modules.library.api.dto.PickerDto;
|
||||||
@@ -88,7 +89,8 @@ public class LibraryController {
|
|||||||
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
|
@RequestParam(defaultValue = "playtimeMinutes,desc") String sort,
|
||||||
@RequestParam(required = false) GameStatus status,
|
@RequestParam(required = false) GameStatus status,
|
||||||
@RequestParam(required = false) String q,
|
@RequestParam(required = false) String q,
|
||||||
@RequestParam(required = false) Integer maxMinutes
|
@RequestParam(required = false) Integer maxMinutes,
|
||||||
|
@RequestParam(defaultValue = "false") boolean hidden
|
||||||
) {
|
) {
|
||||||
Long userId = principal.getIdAsLong();
|
Long userId = principal.getIdAsLong();
|
||||||
if (principal.steamId() != null) {
|
if (principal.steamId() != null) {
|
||||||
@@ -98,7 +100,7 @@ public class LibraryController {
|
|||||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||||
int safePage = Math.max(0, page);
|
int safePage = Math.max(0, page);
|
||||||
Sort safeSort = parseSort(sort);
|
Sort safeSort = parseSort(sort);
|
||||||
var dtoPage = queryService.listOwnedGames(userId, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
|
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, PageRequest.of(safePage, safeSize, safeSort));
|
||||||
return ResponseEntity.ok(PageDto.of(dtoPage));
|
return ResponseEntity.ok(PageDto.of(dtoPage));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +164,19 @@ public class LibraryController {
|
|||||||
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PutMapping("/games/{appid}/hidden")
|
||||||
|
public ResponseEntity<?> updateHidden(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("appid") Long appid,
|
||||||
|
@Valid @RequestBody HiddenUpdateDto update) {
|
||||||
|
return switch (backlogService.setHidden(principal.getIdAsLong(), appid, update.hidden())) {
|
||||||
|
case DONE -> ResponseEntity.ok(update);
|
||||||
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"));
|
||||||
|
case MANUAL_GAME -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
.body(Map.of("code", "MANUAL_GAME", "message", "A game added by hand can be deleted, not hidden"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
@PutMapping("/games/{appid}/status")
|
@PutMapping("/games/{appid}/status")
|
||||||
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@PathVariable("appid") Long appid,
|
@PathVariable("appid") Long appid,
|
||||||
|
|||||||
@@ -31,7 +31,9 @@ public record GameDetailDto(
|
|||||||
String platform,
|
String platform,
|
||||||
String imageUrl,
|
String imageUrl,
|
||||||
// null until a check finds a speedrun.com game with verified runs
|
// null until a check finds a speedrun.com game with verified runs
|
||||||
String speedrunUrl
|
String speedrunUrl,
|
||||||
|
// see GameDto#hidden
|
||||||
|
boolean hidden
|
||||||
) {
|
) {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.GameStatus;
|
|||||||
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
|
* @param achievementsUnlocked null when not fetched yet or hidden by Steam
|
||||||
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
|
* @param platform where the user owns a game added by hand, e.g. "PS5"; null for Steam games
|
||||||
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
|
* @param imageUrl a game added by hand from IGDB, or a repaired Steam header; null means the fixed Steam CDN header
|
||||||
|
* @param hidden the user hid the game; it shows only under the library's hidden filter
|
||||||
*/
|
*/
|
||||||
public record GameDto(
|
public record GameDto(
|
||||||
Long appid,
|
Long appid,
|
||||||
@@ -24,5 +25,6 @@ public record GameDto(
|
|||||||
Integer achievementsTotal,
|
Integer achievementsTotal,
|
||||||
GameSource source,
|
GameSource source,
|
||||||
String platform,
|
String platform,
|
||||||
String imageUrl
|
String imageUrl,
|
||||||
|
boolean hidden
|
||||||
) {}
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
|
||||||
|
public record HiddenUpdateDto(@NotNull Boolean hidden) {}
|
||||||
@@ -48,6 +48,10 @@ public class UserGame {
|
|||||||
@Column(name = "removed_at")
|
@Column(name = "removed_at")
|
||||||
private Instant removedAt;
|
private Instant removedAt;
|
||||||
|
|
||||||
|
/** Set for a Steam game the user hid; the sync never changes it. */
|
||||||
|
@Column(name = "hidden_at")
|
||||||
|
private Instant hiddenAt;
|
||||||
|
|
||||||
@Enumerated(EnumType.STRING)
|
@Enumerated(EnumType.STRING)
|
||||||
@Column(name = "status", nullable = false, length = 16)
|
@Column(name = "status", nullable = false, length = 16)
|
||||||
private GameStatus status;
|
private GameStatus status;
|
||||||
|
|||||||
@@ -23,9 +23,13 @@ public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, Playti
|
|||||||
@Param("day") LocalDate day, @Param("minutes") int minutes);
|
@Param("day") LocalDate day, @Param("minutes") int minutes);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only days with playtime; the caller fills the gaps.
|
* Only days with playtime; the caller fills the gaps. A hidden game's days never count, also those from before
|
||||||
|
* it was hidden.
|
||||||
*/
|
*/
|
||||||
@Query("select new core_service.modules.library.domain.DayMinutes(p.day, sum(p.minutes)) from PlaytimeDay p " +
|
@Query("select new core_service.modules.library.domain.DayMinutes(p.day, sum(p.minutes)) from PlaytimeDay p " +
|
||||||
"where p.userId = :userId and p.day >= :from group by p.day order by p.day")
|
"where p.userId = :userId and p.day >= :from " +
|
||||||
|
"and not exists (select 1 from UserGame ug where ug.userId = p.userId and ug.appid = p.appid " +
|
||||||
|
"and ug.hiddenAt is not null) " +
|
||||||
|
"group by p.day order by p.day")
|
||||||
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
|
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
|
||||||
}
|
}
|
||||||
@@ -29,6 +29,12 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
*/
|
*/
|
||||||
long NO_GAME = 0;
|
long NO_GAME = 0;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The games a user's lists and numbers show: still owned and not hidden. Needs the alias {@code ug} and the
|
||||||
|
* parameter {@code :userId}.
|
||||||
|
*/
|
||||||
|
String LISTED = "ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is null ";
|
||||||
|
|
||||||
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
|
List<UserGame> findByUserIdAndRemovedAtIsNull(Long userId);
|
||||||
|
|
||||||
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids")
|
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.appid in :appids")
|
||||||
@@ -38,24 +44,34 @@ 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 " +
|
@Query("select ug.appid from UserGame ug where " + LISTED +
|
||||||
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
||||||
List<Long> findBacklogAppids(@Param("userId") Long userId);
|
List<Long> findBacklogAppids(@Param("userId") Long userId);
|
||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNull(Long userId);
|
@Query("select ug.appid from UserGame ug where " + LISTED + "and ug.appid in :appids")
|
||||||
|
List<Long> findListedAppids(@Param("userId") Long userId, @Param("appids") Collection<Long> appids);
|
||||||
|
|
||||||
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where ug.userId = :userId and ug.removedAt is null")
|
@Query("select ug.appid from UserGame ug where ug.userId = :userId and ug.removedAt is null and ug.hiddenAt is not null")
|
||||||
|
List<Long> findHiddenAppids(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Query("select count(ug) from UserGame ug where " + LISTED)
|
||||||
|
long countListed(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Query("select coalesce(sum(ug.playtimeMinutes), 0) from UserGame ug where " + LISTED)
|
||||||
long sumPlaytime(@Param("userId") Long userId);
|
long sumPlaytime(@Param("userId") Long userId);
|
||||||
|
|
||||||
List<UserGame> findByUserIdAndRemovedAtIsNullAndStatusIn(Long userId, Collection<GameStatus> statuses, Pageable pageable);
|
@Query("select ug from UserGame ug where " + LISTED + "and ug.status in :statuses")
|
||||||
|
List<UserGame> findListedByStatusIn(@Param("userId") Long userId, @Param("statuses") Collection<GameStatus> statuses,
|
||||||
|
Pageable pageable);
|
||||||
|
|
||||||
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
|
@Query("select ug from UserGame ug where " + LISTED + "and ug.playtime2Weeks > 0 order by ug.playtime2Weeks desc")
|
||||||
Long userId, int playtime2Weeks);
|
List<UserGame> findListedPlayedInTwoWeeks(@Param("userId") Long userId, Pageable pageable);
|
||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
|
@Query("select count(ug) from UserGame ug where " + LISTED + "and ug.playtimeMinutes = 0")
|
||||||
|
long countListedNeverPlayed(@Param("userId") Long userId);
|
||||||
|
|
||||||
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
|
@Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " +
|
||||||
"where ug.userId = :userId and ug.removedAt is null group by ug.status")
|
"where " + LISTED + "group by ug.status")
|
||||||
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
List<StatusCount> countByStatus(@Param("userId") Long userId);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,7 +82,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
*
|
*
|
||||||
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
|
* @param excludeAppid never picked; {@link #NO_GAME} excludes nothing
|
||||||
*/
|
*/
|
||||||
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and status = 'BACKLOG' " +
|
@Query(value = "select * from user_game where user_id = :userId and removed_at is null and hidden_at is null " +
|
||||||
|
"and status = 'BACKLOG' " +
|
||||||
"and appid <> :excludeAppid " +
|
"and appid <> :excludeAppid " +
|
||||||
// Weighted random pick: the smallest -ln(u) / weight wins.
|
// Weighted random pick: the smallest -ln(u) / weight wins.
|
||||||
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
|
"order by -ln(1 - random()) / case priority when 'HIGH' then 4 when 'MEDIUM' then 2 else 1 end " +
|
||||||
@@ -78,7 +95,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
"from UserGame ug " +
|
"from UserGame ug " +
|
||||||
"left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " +
|
"left join GameAchievementSchemaMeta sm on sm.appid = ug.appid " +
|
||||||
"left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " +
|
"left join UserGameAchievementMeta um on um.userId = ug.userId and um.appid = ug.appid " +
|
||||||
"where ug.userId = :userId and ug.removedAt is null " +
|
"where " + LISTED +
|
||||||
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
|
"and ug.source = core_service.modules.library.domain.GameSource.STEAM")
|
||||||
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
|
List<AchievementProgress> findAchievementProgress(@Param("userId") Long userId);
|
||||||
|
|
||||||
@@ -99,7 +116,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@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.hidden_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 " +
|
"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 " +
|
"and (cast(:mood as varchar) is null or exists (select 1 from game_mood m " +
|
||||||
@@ -116,17 +133,20 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
@Param("count") int count);
|
@Param("count") int count);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param hidden true for only the hidden games, false for only the listed ones
|
||||||
* @param status null for every status
|
* @param status null for every status
|
||||||
* @param namePattern a {@link #containsPattern} result, null for every name
|
* @param namePattern a {@link #containsPattern} result, null for every name
|
||||||
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
* @param maxMinutes only games whose normal time to beat is known and at most this; null for every game
|
||||||
*/
|
*/
|
||||||
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
@Query("select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
|
||||||
"where ug.userId = :userId and ug.removedAt is null " +
|
"where ug.userId = :userId and ug.removedAt is null " +
|
||||||
|
"and ((:hidden = true and ug.hiddenAt is not null) or (:hidden = false and ug.hiddenAt is null)) " +
|
||||||
"and (:status is null or ug.status = :status) " +
|
"and (:status is null or ug.status = :status) " +
|
||||||
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
|
"and (:namePattern is null or lower(sg.name) like :namePattern escape '\\') " +
|
||||||
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
"and (:maxMinutes is null or exists (select 1 from GameTimeToBeat t " +
|
||||||
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
|
" where t.appid = ug.appid and t.normallyMinutes <= :maxMinutes))")
|
||||||
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
Page<UserGame> findOwnedGames(@Param("userId") Long userId,
|
||||||
|
@Param("hidden") boolean hidden,
|
||||||
@Param("status") GameStatus status,
|
@Param("status") GameStatus status,
|
||||||
@Param("namePattern") String namePattern,
|
@Param("namePattern") String namePattern,
|
||||||
@Param("maxMinutes") Integer maxMinutes,
|
@Param("maxMinutes") Integer maxMinutes,
|
||||||
@@ -136,7 +156,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
* Normal time to beat of the user's backlog, only games IGDB has data for.
|
* Normal time to beat of the user's backlog, only games IGDB has data for.
|
||||||
*/
|
*/
|
||||||
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
|
@Query("select coalesce(sum(t.normallyMinutes), 0) from UserGame ug join GameTimeToBeat t on t.appid = ug.appid " +
|
||||||
"where ug.userId = :userId and ug.removedAt is null " +
|
"where " + LISTED +
|
||||||
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
"and ug.status = core_service.modules.library.domain.GameStatus.BACKLOG")
|
||||||
long sumBacklogMinutes(@Param("userId") Long userId);
|
long sumBacklogMinutes(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
|||||||
@@ -143,7 +143,7 @@ public class AchievementSyncService {
|
|||||||
|
|
||||||
private void runLibrarySync(Long userId, String steamId) {
|
private void runLibrarySync(Long userId, String steamId) {
|
||||||
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
|
List<UserGame> games = userGameRepository.findByUserIdAndRemovedAtIsNull(userId).stream()
|
||||||
.filter(game -> game.getSource() == GameSource.STEAM)
|
.filter(game -> game.getSource() == GameSource.STEAM && game.getHiddenAt() == null)
|
||||||
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
|
.sorted(Comparator.comparingInt(UserGame::getPlaytimeMinutes).reversed())
|
||||||
.toList();
|
.toList();
|
||||||
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
|
Map<Long, GameAchievementSchemaMeta> schemas = schemaMetaRepository
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package core_service.modules.library.service;
|
package core_service.modules.library.service;
|
||||||
|
|
||||||
import core_service.modules.library.api.dto.BacklogEntryDto;
|
import core_service.modules.library.api.dto.BacklogEntryDto;
|
||||||
|
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.UserGame;
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.modules.library.domain.UserGameId;
|
import core_service.modules.library.domain.UserGameId;
|
||||||
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
|
|||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@@ -48,6 +50,28 @@ public class BacklogService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public enum HideResult { DONE, NOT_FOUND, MANUAL_GAME }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hides only a Steam game; one added by hand can be deleted instead. Unhiding works for every game. Hiding again
|
||||||
|
* keeps the first time.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public HideResult setHidden(Long userId, Long appid, boolean hidden) {
|
||||||
|
Optional<UserGame> game = userGameRepository.findById(new UserGameId(userId, appid))
|
||||||
|
.filter(found -> found.getRemovedAt() == null);
|
||||||
|
if (game.isEmpty()) return HideResult.NOT_FOUND;
|
||||||
|
if (!hidden) {
|
||||||
|
game.get().setHiddenAt(null);
|
||||||
|
return HideResult.DONE;
|
||||||
|
}
|
||||||
|
if (game.get().getSource() != GameSource.STEAM) return HideResult.MANUAL_GAME;
|
||||||
|
if (game.get().getHiddenAt() == null) {
|
||||||
|
game.get().setHiddenAt(Instant.now());
|
||||||
|
}
|
||||||
|
return HideResult.DONE;
|
||||||
|
}
|
||||||
|
|
||||||
private static BacklogEntryDto toDto(UserGame game) {
|
private static BacklogEntryDto toDto(UserGame game) {
|
||||||
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,8 +65,12 @@ public class GameDetailService {
|
|||||||
}
|
}
|
||||||
UserGame userGame = userGameOpt.get();
|
UserGame userGame = userGameOpt.get();
|
||||||
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
|
||||||
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
|
// A hidden game gets no calls to Steam, IGDB or speedrun.com; it shows what is stored.
|
||||||
speedrunService.refreshInBackground(List.of(appid));
|
boolean hidden = userGame.getHiddenAt() != null;
|
||||||
|
if (!hidden) {
|
||||||
|
// Only backlog games are checked in bulk; any other game gets its link on a later visit.
|
||||||
|
speedrunService.refreshInBackground(List.of(appid));
|
||||||
|
}
|
||||||
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));
|
||||||
@@ -74,10 +78,12 @@ public class GameDetailService {
|
|||||||
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (!hidden) {
|
||||||
achievementSyncService.refreshSchemaIfStale(appid);
|
try {
|
||||||
} catch (Exception e) {
|
achievementSyncService.refreshSchemaIfStale(appid);
|
||||||
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
|
} catch (Exception e) {
|
||||||
|
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
|
||||||
|
|
||||||
@@ -85,7 +91,7 @@ public class GameDetailService {
|
|||||||
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
return Optional.of(buildDto(userGame, meta, true, 0, 0, List.of()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (steamId != null) {
|
if (steamId != null && !hidden) {
|
||||||
try {
|
try {
|
||||||
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
|
||||||
Duration.ofHours(achievementTtlHours));
|
Duration.ofHours(achievementTtlHours));
|
||||||
@@ -155,7 +161,8 @@ public class GameDetailService {
|
|||||||
userGame.getSource(),
|
userGame.getSource(),
|
||||||
userGame.getPlatform(),
|
userGame.getPlatform(),
|
||||||
meta != null ? meta.getImageUrl() : null,
|
meta != null ? meta.getImageUrl() : null,
|
||||||
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null)
|
gameSpeedrunRepository.findById(userGame.getAppid()).map(GameSpeedrun::getWeblink).orElse(null),
|
||||||
|
userGame.getHiddenAt() != null
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,10 +80,10 @@ public class ImageRepairService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Only for a Steam game the user owns; anything else is dropped.
|
* Only for a listed Steam game of the user: a hidden game gets no Steam calls. Anything else is dropped.
|
||||||
*/
|
*/
|
||||||
public void report(Long userId, Long appid, ImageKind kind) {
|
public void report(Long userId, Long appid, ImageKind kind) {
|
||||||
if (!GameKeys.isSteam(appid) || userGameRepository.findActiveAppids(userId, List.of(appid)).isEmpty()) {
|
if (!GameKeys.isSteam(appid) || userGameRepository.findListedAppids(userId, List.of(appid)).isEmpty()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
brokenImageRepository.report(appid, kind.name(), Instant.now());
|
brokenImageRepository.report(appid, kind.name(), Instant.now());
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ public class LibraryQueryService {
|
|||||||
private static final int PUBLIC_PLAYING = 8;
|
private static final int PUBLIC_PLAYING = 8;
|
||||||
private static final int PUBLIC_FINISHED = 12;
|
private static final int PUBLIC_FINISHED = 12;
|
||||||
private static final int CLOSEST_GAMES = 5;
|
private static final int CLOSEST_GAMES = 5;
|
||||||
|
private static final int RECENT_GAMES = 5;
|
||||||
|
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||||
@@ -69,15 +70,15 @@ public class LibraryQueryService {
|
|||||||
private ZoneId zone;
|
private ZoneId zone;
|
||||||
|
|
||||||
public long countOwnedGames(Long userId) {
|
public long countOwnedGames(Long userId) {
|
||||||
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
|
return userGameRepository.countListed(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Page<GameDto> listOwnedGames(Long userId, GameStatus status, String nameQuery, Integer maxMinutes,
|
public Page<GameDto> listOwnedGames(Long userId, boolean hidden, GameStatus status, String nameQuery,
|
||||||
Pageable pageable) {
|
Integer maxMinutes, Pageable pageable) {
|
||||||
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
Pageable stable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(),
|
||||||
UserGameRepository.querySort(pageable.getSort()));
|
UserGameRepository.querySort(pageable.getSort()));
|
||||||
Page<UserGame> page = userGameRepository.findOwnedGames(
|
Page<UserGame> page = userGameRepository.findOwnedGames(
|
||||||
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
userId, hidden, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
|
||||||
List<GameDto> games = toDtos(page.getContent());
|
List<GameDto> games = toDtos(page.getContent());
|
||||||
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
|
||||||
}
|
}
|
||||||
@@ -155,7 +156,7 @@ public class LibraryQueryService {
|
|||||||
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
|
public LibrarySummaryDto getSummary(Long userId, Long excludeAppid) {
|
||||||
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
||||||
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
long total = byStatus.values().stream().mapToLong(Long::longValue).sum();
|
||||||
long neverPlayed = userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(userId, 0);
|
long neverPlayed = userGameRepository.countListedNeverPlayed(userId);
|
||||||
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME)
|
GameDto suggestion = userGameRepository.findRandomBacklogGame(userId, excludeAppid != null ? excludeAppid : UserGameRepository.NO_GAME)
|
||||||
.map(ug -> toDtos(List.of(ug)).getFirst())
|
.map(ug -> toDtos(List.of(ug)).getFirst())
|
||||||
.orElse(null);
|
.orElse(null);
|
||||||
@@ -175,8 +176,7 @@ public class LibraryQueryService {
|
|||||||
.toList();
|
.toList();
|
||||||
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
|
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
|
||||||
|
|
||||||
List<UserGame> recent = userGameRepository
|
List<UserGame> recent = userGameRepository.findListedPlayedInTwoWeeks(userId, PageRequest.of(0, RECENT_GAMES));
|
||||||
.findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0);
|
|
||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
|
||||||
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
List<RecentGameDto> recentGames = recent.stream()
|
List<RecentGameDto> recentGames = recent.stream()
|
||||||
@@ -193,10 +193,10 @@ public class LibraryQueryService {
|
|||||||
|
|
||||||
public PublicLibraryDto getPublicLibrary(Long userId) {
|
public PublicLibraryDto getPublicLibrary(Long userId) {
|
||||||
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
Map<GameStatus, Long> byStatus = countByStatus(userId);
|
||||||
List<UserGame> playing = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
|
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId,
|
||||||
List.of(GameStatus.PLAYING),
|
List.of(GameStatus.PLAYING),
|
||||||
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
PageRequest.of(0, PUBLIC_PLAYING, Sort.by(Sort.Direction.DESC, "playtimeMinutes").and(Sort.by("appid"))));
|
||||||
List<UserGame> finished = userGameRepository.findByUserIdAndRemovedAtIsNullAndStatusIn(userId,
|
List<UserGame> finished = userGameRepository.findListedByStatusIn(userId,
|
||||||
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
List.of(GameStatus.FINISHED, GameStatus.COMPLETED),
|
||||||
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
|
PageRequest.of(0, PUBLIC_FINISHED, Sort.by(Sort.Order.desc("rating").nullsLast()).and(Sort.by("appid"))));
|
||||||
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(
|
||||||
@@ -254,7 +254,8 @@ public class LibraryQueryService {
|
|||||||
achievementsTotal,
|
achievementsTotal,
|
||||||
ug.getSource(),
|
ug.getSource(),
|
||||||
ug.getPlatform(),
|
ug.getPlatform(),
|
||||||
meta != null ? meta.getImageUrl() : null);
|
meta != null ? meta.getImageUrl() : null,
|
||||||
|
ug.getHiddenAt() != null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
public Optional<UserSteamSync> getSyncStatus(Long userId) {
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import java.time.Instant;
|
|||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneId;
|
import java.time.ZoneId;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
@@ -122,6 +123,7 @@ public class LibrarySyncWorker {
|
|||||||
// 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> igdbAppids = 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());
|
||||||
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
igdbAppids.addAll(userGameRepository.findActiveManualAppids(userId));
|
||||||
|
igdbAppids.removeAll(new HashSet<>(userGameRepository.findHiddenAppids(userId)));
|
||||||
timeToBeatService.refresh(igdbAppids);
|
timeToBeatService.refresh(igdbAppids);
|
||||||
moodService.refresh(igdbAppids);
|
moodService.refresh(igdbAppids);
|
||||||
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
eventPublisher.publishEvent(new LibrarySyncedEvent(userId, steamId));
|
||||||
|
|||||||
@@ -171,6 +171,8 @@ public class ManualGameService {
|
|||||||
revived.setPlaytime2Weeks(0);
|
revived.setPlaytime2Weeks(0);
|
||||||
revived.setLastSeenAt(now);
|
revived.setLastSeenAt(now);
|
||||||
revived.setRemovedAt(null);
|
revived.setRemovedAt(null);
|
||||||
|
// Only a Steam game can be hidden.
|
||||||
|
revived.setHiddenAt(null);
|
||||||
} else {
|
} else {
|
||||||
userGameRepository.save(UserGame.manual(userId, appid, platform, status, playtimeMinutes, now));
|
userGameRepository.save(UserGame.manual(userId, appid, platform, status, playtimeMinutes, now));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,31 +19,31 @@ public class StatsRepository {
|
|||||||
|
|
||||||
public long countDistinctGames() {
|
public long countDistinctGames() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL", Long.class);
|
"SELECT COUNT(DISTINCT appid) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countLibraryEntries() {
|
public long countLibraryEntries() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL", Long.class);
|
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countGamesPlayed() {
|
public long countGamesPlayed() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND playtime_minutes > 0", Long.class);
|
"SELECT COUNT(*) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes > 0", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long sumPlaytimeMinutes() {
|
public long sumPlaytimeMinutes() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE removed_at IS NULL", Long.class);
|
"SELECT COALESCE(SUM(playtime_minutes), 0) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
public long countUsersWithLibrary() {
|
public long countUsersWithLibrary() {
|
||||||
Long v = jdbc.queryForObject(
|
Long v = jdbc.queryForObject(
|
||||||
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL", Long.class);
|
"SELECT COUNT(DISTINCT user_id) FROM user_game WHERE removed_at IS NULL AND hidden_at IS NULL", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ public class StatsRepository {
|
|||||||
Long v = jdbc.queryForObject("""
|
Long v = jdbc.queryForObject("""
|
||||||
SELECT COALESCE(MAX(c), 0) FROM (
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
SELECT COUNT(*) AS c FROM user_game
|
SELECT COUNT(*) AS c FROM user_game
|
||||||
WHERE removed_at IS NULL GROUP BY user_id
|
WHERE removed_at IS NULL AND hidden_at IS NULL GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""", Long.class);
|
""", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
@@ -61,7 +61,7 @@ public class StatsRepository {
|
|||||||
Long v = jdbc.queryForObject("""
|
Long v = jdbc.queryForObject("""
|
||||||
SELECT COALESCE(MAX(c), 0) FROM (
|
SELECT COALESCE(MAX(c), 0) FROM (
|
||||||
SELECT COUNT(*) AS c FROM user_game
|
SELECT COUNT(*) AS c FROM user_game
|
||||||
WHERE removed_at IS NULL AND playtime_minutes = 0 GROUP BY user_id
|
WHERE removed_at IS NULL AND hidden_at IS NULL AND playtime_minutes = 0 GROUP BY user_id
|
||||||
) t
|
) t
|
||||||
""", Long.class);
|
""", Long.class);
|
||||||
return v != null ? v : 0L;
|
return v != null ? v : 0L;
|
||||||
@@ -72,7 +72,7 @@ public class StatsRepository {
|
|||||||
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
|
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, COUNT(*) AS value
|
||||||
FROM user_game ug
|
FROM user_game ug
|
||||||
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
||||||
WHERE ug.removed_at IS NULL
|
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
|
||||||
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
@@ -84,7 +84,7 @@ public class StatsRepository {
|
|||||||
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value
|
SELECT ug.appid, sg.name, sg.img_icon_url, sg.image_url, SUM(ug.playtime_minutes) AS value
|
||||||
FROM user_game ug
|
FROM user_game ug
|
||||||
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
LEFT JOIN steam_game sg ON sg.appid = ug.appid
|
||||||
WHERE ug.removed_at IS NULL
|
WHERE ug.removed_at IS NULL AND ug.hidden_at IS NULL
|
||||||
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
GROUP BY ug.appid, sg.name, sg.img_icon_url, sg.image_url
|
||||||
ORDER BY value DESC
|
ORDER BY value DESC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
-- A game the user hid (e.g. a playtest or a tool). It stays owned and synced, but leaves every list and number.
|
||||||
|
ALTER TABLE user_game ADD COLUMN hidden_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
DROP INDEX idx_user_game_user_active;
|
||||||
|
CREATE INDEX idx_user_game_user_active ON user_game (user_id) WHERE removed_at IS NULL AND hidden_at IS NULL;
|
||||||
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.BrokenImageId;
|
|||||||
import core_service.modules.library.domain.ImageKind;
|
import core_service.modules.library.domain.ImageKind;
|
||||||
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.repo.BrokenImageRepository;
|
import core_service.modules.library.repo.BrokenImageRepository;
|
||||||
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;
|
||||||
@@ -71,6 +72,19 @@ class LibraryControllerBrokenImageTest extends AbstractIntegrationTest {
|
|||||||
assertThat(brokenImageRepository.count()).isZero();
|
assertThat(brokenImageRepository.count()).isZero();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reportBrokenImage_ForAHiddenGameStoresNothing() throws Exception {
|
||||||
|
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, APPID)).orElseThrow();
|
||||||
|
hidden.setHiddenAt(Instant.now());
|
||||||
|
userGameRepository.save(hidden);
|
||||||
|
|
||||||
|
mockMvc.perform(post("/api/library/broken-images").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"appid\":220,\"kind\":\"COVER\"}"))
|
||||||
|
.andExpect(status().isNoContent());
|
||||||
|
|
||||||
|
assertThat(brokenImageRepository.count()).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@ParameterizedTest
|
@ParameterizedTest
|
||||||
@ValueSource(strings = {"{\"appid\":220,\"kind\":\"BANNER\"}", "{\"appid\":220}", "{\"kind\":\"COVER\"}"})
|
@ValueSource(strings = {"{\"appid\":220,\"kind\":\"BANNER\"}", "{\"appid\":220}", "{\"kind\":\"COVER\"}"})
|
||||||
void reportBrokenImage_BadBodyIs400(String body) throws Exception {
|
void reportBrokenImage_BadBodyIs400(String body) throws Exception {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.configuration.security.ClientHeaderFilter;
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
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.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.test.AbstractIntegrationTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
|
import org.junit.jupiter.params.provider.ValueSource;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.test.web.servlet.ResultActions;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.temporal.ChronoUnit;
|
||||||
|
|
||||||
|
import static org.hamcrest.Matchers.hasSize;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
|
||||||
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The dev profile authenticates every request as user 1.
|
||||||
|
*/
|
||||||
|
@AutoConfigureMockMvc
|
||||||
|
class LibraryControllerHiddenTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
steamGameRepository.save(new SteamGame(220L, "Half-Life 2", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(1000L, "Some Playtest", null, Instant.now()));
|
||||||
|
steamGameRepository.save(new SteamGame(-1942L, "Zelda", null, Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now()));
|
||||||
|
userGameRepository.save(new UserGame(USER_ID, 1000L, 0, Instant.now(), Instant.now()));
|
||||||
|
userGameRepository.save(UserGame.manual(USER_ID, -1942L, "Switch", GameStatus.PLAYING, 60, Instant.now()));
|
||||||
|
}
|
||||||
|
|
||||||
|
private ResultActions setHidden(long appid, String body) throws Exception {
|
||||||
|
return mockMvc.perform(put("/api/library/games/" + appid + "/hidden").header(ClientHeaderFilter.CLIENT_HEADER, "test")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content(body));
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame stored(long appid) {
|
||||||
|
return userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hideAndUnhide_MoveTheGameBetweenTheLists() throws Exception {
|
||||||
|
setHidden(1000L, "{\"hidden\":true}")
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.hidden").value(true));
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/games"))
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(2)))
|
||||||
|
.andExpect(jsonPath("$.content[?(@.appid == 1000)]").isEmpty());
|
||||||
|
mockMvc.perform(get("/api/library/games?hidden=true"))
|
||||||
|
.andExpect(jsonPath("$.content", hasSize(1)))
|
||||||
|
.andExpect(jsonPath("$.content[0].appid").value(1000))
|
||||||
|
.andExpect(jsonPath("$.content[0].hidden").value(true));
|
||||||
|
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(2));
|
||||||
|
mockMvc.perform(get("/api/library/summary")).andExpect(jsonPath("$.total").value(2));
|
||||||
|
mockMvc.perform(get("/api/library/games/1000"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.hidden").value(true));
|
||||||
|
|
||||||
|
setHidden(1000L, "{\"hidden\":false}").andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertNull(stored(1000L).getHiddenAt());
|
||||||
|
mockMvc.perform(get("/api/library/games-count")).andExpect(jsonPath("$.count").value(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_AHiddenGameKeepsTheFirstTime() throws Exception {
|
||||||
|
Instant first = Instant.now().minus(3, ChronoUnit.DAYS).truncatedTo(ChronoUnit.MICROS);
|
||||||
|
UserGame game = stored(1000L);
|
||||||
|
game.setHiddenAt(first);
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertEquals(first, stored(1000L).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_UnknownGameIs404() throws Exception {
|
||||||
|
setHidden(620L, "{\"hidden\":true}")
|
||||||
|
.andExpect(status().isNotFound())
|
||||||
|
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_RemovedGameIs404() throws Exception {
|
||||||
|
UserGame game = stored(1000L);
|
||||||
|
game.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
setHidden(1000L, "{\"hidden\":true}").andExpect(status().isNotFound());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_GameAddedByHandIs409() throws Exception {
|
||||||
|
setHidden(-1942L, "{\"hidden\":true}")
|
||||||
|
.andExpect(status().isConflict())
|
||||||
|
.andExpect(jsonPath("$.code").value("MANUAL_GAME"));
|
||||||
|
|
||||||
|
assertNull(stored(-1942L).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unhide_WorksForAnyGame() throws Exception {
|
||||||
|
UserGame manual = stored(-1942L);
|
||||||
|
manual.setHiddenAt(Instant.now());
|
||||||
|
userGameRepository.save(manual);
|
||||||
|
|
||||||
|
setHidden(-1942L, "{\"hidden\":false}").andExpect(status().isOk());
|
||||||
|
|
||||||
|
assertNull(stored(-1942L).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@ParameterizedTest
|
||||||
|
@ValueSource(strings = {"{}", "{\"hidden\":null}", "{\"hidden\":\"maybe\"}"})
|
||||||
|
void hide_BadBodyIs400(String body) throws Exception {
|
||||||
|
setHidden(1000L, body).andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
assertNull(stored(1000L).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_WithoutClientHeaderIs403() throws Exception {
|
||||||
|
mockMvc.perform(put("/api/library/games/1000/hidden")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON).content("{\"hidden\":true}"))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
|
||||||
|
assertNull(stored(1000L).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hide_KeepsStatusAndNotes() throws Exception {
|
||||||
|
UserGame game = stored(220L);
|
||||||
|
game.setNotes("keep me");
|
||||||
|
userGameRepository.save(game);
|
||||||
|
|
||||||
|
setHidden(220L, "{\"hidden\":true}").andExpect(status().isOk());
|
||||||
|
|
||||||
|
UserGame after = stored(220L);
|
||||||
|
assertNotNull(after.getHiddenAt());
|
||||||
|
assertEquals(GameStatus.PLAYING, after.getStatus());
|
||||||
|
assertEquals("keep me", after.getNotes());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -174,6 +174,19 @@ class ManualGameControllerTest extends AbstractIntegrationTest {
|
|||||||
assertEquals(600, back.getPlaytimeMinutes());
|
assertEquals(600, back.getPlaytimeMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void add_HiddenSteamGameRemovedBefore_ComesBackShown() throws Exception {
|
||||||
|
steamGameRepository.save(new SteamGame(1245620L, "ELDEN RING", null, Instant.now()));
|
||||||
|
UserGame refunded = new UserGame(USER_ID, 1245620L, 300, Instant.now(), Instant.now());
|
||||||
|
refunded.setHiddenAt(Instant.now());
|
||||||
|
refunded.setRemovedAt(Instant.now());
|
||||||
|
userGameRepository.save(refunded);
|
||||||
|
|
||||||
|
add(119133, "PS5").andExpect(status().isCreated());
|
||||||
|
|
||||||
|
assertNull(stored(1245620).getHiddenAt());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void add_UnknownIgdbGame_Is400() throws Exception {
|
void add_UnknownIgdbGame_Is400() throws Exception {
|
||||||
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
|
when(igdbApiService.getGame(anyLong())).thenReturn(Optional.empty());
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
package core_service.modules.library.repo;
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
import core_service.modules.library.domain.DayMinutes;
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
import core_service.shared.test.AbstractPersistenceTest;
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
import java.time.LocalDate;
|
import java.time.LocalDate;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -18,6 +21,12 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private PlaytimeDayRepository playtimeDayRepository;
|
private PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void addMinutes_SameGameAndDay_AddsUp() {
|
void addMinutes_SameGameAndDay_AddsUp() {
|
||||||
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||||
@@ -37,4 +46,25 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
|
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
|
||||||
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minutesPerDay_LeavesOutHiddenGames() {
|
||||||
|
steamGameRepository.saveAll(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||||
|
new SteamGame(620L, "Portal 2", null, Instant.now())));
|
||||||
|
UserGame hidden = new UserGame(USER_ID, 620L, 60, Instant.now(), Instant.now());
|
||||||
|
hidden.setHiddenAt(Instant.now());
|
||||||
|
userGameRepository.saveAll(List.of(new UserGame(USER_ID, 220L, 30, Instant.now(), Instant.now()), hidden,
|
||||||
|
// Another user's hidden flag does not hide the game for this user.
|
||||||
|
hiddenFor(2L, 220L)));
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 60);
|
||||||
|
|
||||||
|
assertEquals(List.of(new DayMinutes(DAY, 30)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserGame hiddenFor(long userId, long appid) {
|
||||||
|
UserGame game = new UserGame(userId, appid, 0, Instant.now(), Instant.now());
|
||||||
|
game.setHiddenAt(Instant.now());
|
||||||
|
return game;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -61,7 +61,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Long> appids(GameStatus status, String query) {
|
private List<Long> appids(GameStatus status, String query) {
|
||||||
return userGameRepository.findOwnedGames(USER_ID, status, UserGameRepository.containsPattern(query), null, PAGE)
|
return userGameRepository.findOwnedGames(USER_ID, false, status, UserGameRepository.containsPattern(query), null, PAGE)
|
||||||
.map(UserGame::getAppid).getContent();
|
.map(UserGame::getAppid).getContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
void findOwnedGames_CountsAllMatchesAcrossPages() {
|
||||||
var page = userGameRepository.findOwnedGames(USER_ID, null, UserGameRepository.containsPattern("half"), null,
|
var page = userGameRepository.findOwnedGames(USER_ID, false, null, UserGameRepository.containsPattern("half"), null,
|
||||||
PageRequest.of(0, 1, Sort.by("appid")));
|
PageRequest.of(0, 1, Sort.by("appid")));
|
||||||
|
|
||||||
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
assertEquals(List.of(220L), page.map(UserGame::getAppid).getContent());
|
||||||
@@ -144,7 +144,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private List<Long> sortedAppids(Sort sort) {
|
private List<Long> sortedAppids(Sort sort) {
|
||||||
return userGameRepository.findOwnedGames(USER_ID, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
return userGameRepository.findOwnedGames(USER_ID, false, null, null, null, PageRequest.of(0, 20, UserGameRepository.querySort(sort)))
|
||||||
.map(UserGame::getAppid).getContent();
|
.map(UserGame::getAppid).getContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -195,7 +195,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
|
new GameTimeToBeat(620L, 300, 480, 900, Instant.now()),
|
||||||
new GameTimeToBeat(380L, null, null, null, Instant.now())));
|
new GameTimeToBeat(380L, null, null, null, Instant.now())));
|
||||||
|
|
||||||
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
|
List<Long> shortGames = userGameRepository.findOwnedGames(USER_ID, false, null, null, 600, PAGE).map(UserGame::getAppid).getContent();
|
||||||
|
|
||||||
assertEquals(List.of(620L), shortGames);
|
assertEquals(List.of(620L), shortGames);
|
||||||
}
|
}
|
||||||
@@ -210,6 +210,65 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
|
assertEquals(350, userGameRepository.sumBacklogMinutes(USER_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void hide(long... appids) {
|
||||||
|
for (long appid : appids) {
|
||||||
|
userGameRepository.findById(new UserGameId(USER_ID, appid)).orElseThrow().setHiddenAt(Instant.now());
|
||||||
|
}
|
||||||
|
userGameRepository.flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenGames_LeaveTheListAndShowOnlyUnderTheHiddenFilter() {
|
||||||
|
hide(620L, 700L);
|
||||||
|
|
||||||
|
assertEquals(List.of(220L, 380L), appids(null, null));
|
||||||
|
assertEquals(List.of(620L, 700L), userGameRepository.findOwnedGames(USER_ID, true, null, null, null,
|
||||||
|
PageRequest.of(0, 20, Sort.by("appid"))).map(UserGame::getAppid).getContent());
|
||||||
|
assertEquals(Set.of(620L, 700L), Set.copyOf(userGameRepository.findHiddenAppids(USER_ID)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenGames_LeaveEveryCountAndSum() {
|
||||||
|
gameTimeToBeatRepository.saveAll(List.of(
|
||||||
|
new GameTimeToBeat(380L, 100, 200, 300, Instant.now()),
|
||||||
|
new GameTimeToBeat(700L, 100, 150, 300, Instant.now())));
|
||||||
|
userGameRepository.findById(new UserGameId(USER_ID, 220L)).orElseThrow().setPlaytime2Weeks(30);
|
||||||
|
userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow().setPlaytime2Weeks(60);
|
||||||
|
hide(620L, 700L);
|
||||||
|
|
||||||
|
assertEquals(2, userGameRepository.countListed(USER_ID));
|
||||||
|
assertEquals(300, userGameRepository.sumPlaytime(USER_ID));
|
||||||
|
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
|
||||||
|
assertEquals(200, userGameRepository.sumBacklogMinutes(USER_ID));
|
||||||
|
assertEquals(Set.of(new StatusCount(GameStatus.PLAYING, 1), new StatusCount(GameStatus.BACKLOG, 1)),
|
||||||
|
Set.copyOf(userGameRepository.countByStatus(USER_ID)));
|
||||||
|
assertEquals(List.of(380L), userGameRepository.findBacklogAppids(USER_ID));
|
||||||
|
assertEquals(List.of(220L), userGameRepository.findListedPlayedInTwoWeeks(USER_ID, PAGE)
|
||||||
|
.stream().map(UserGame::getAppid).toList());
|
||||||
|
assertEquals(List.of(220L), userGameRepository.findListedByStatusIn(USER_ID, List.of(GameStatus.PLAYING), PAGE)
|
||||||
|
.stream().map(UserGame::getAppid).toList());
|
||||||
|
assertEquals(Set.of(220L, 380L), Set.copyOf(userGameRepository.findAchievementProgress(USER_ID)
|
||||||
|
.stream().map(p -> p.appid()).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenGames_AreNeverPicked() {
|
||||||
|
hide(700L);
|
||||||
|
|
||||||
|
for (int i = 0; i < 5; i++) {
|
||||||
|
assertEquals(380L, userGameRepository.findRandomBacklogGame(USER_ID, -1).orElseThrow().getAppid());
|
||||||
|
assertEquals(List.of(380L), userGameRepository.findPickerGames(USER_ID, null, null, List.of(), 10)
|
||||||
|
.stream().map(UserGame::getAppid).toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void findActiveAppids_StillSeesHiddenGames() {
|
||||||
|
hide(700L);
|
||||||
|
|
||||||
|
assertEquals(List.of(700L), userGameRepository.findActiveAppids(USER_ID, List.of(700L)));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void containsPattern_BlankIsNoFilter() {
|
void containsPattern_BlankIsNoFilter() {
|
||||||
assertNull(UserGameRepository.containsPattern(" "));
|
assertNull(UserGameRepository.containsPattern(" "));
|
||||||
@@ -235,7 +294,7 @@ class UserGameRepositoryTest extends AbstractPersistenceTest {
|
|||||||
userGameRepository.save(new UserGame(2L, 380L, 0, Instant.now(), Instant.now()));
|
userGameRepository.save(new UserGame(2L, 380L, 0, Instant.now(), Instant.now()));
|
||||||
userGameRepository.flush();
|
userGameRepository.flush();
|
||||||
|
|
||||||
assertEquals(1, userGameRepository.countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(USER_ID, 0));
|
assertEquals(1, userGameRepository.countListedNeverPlayed(USER_ID));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -124,6 +124,17 @@ class AchievementSyncServiceTest extends AbstractUnitTest {
|
|||||||
verifyNoInteractions(steamApiService);
|
verifyNoInteractions(steamApiService);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncLibrary_HiddenGame_AsksSteamNothing() {
|
||||||
|
UserGame hidden = game(620L, 120);
|
||||||
|
hidden.setHiddenAt(Instant.now());
|
||||||
|
library(List.of(hidden), List.of(), List.of());
|
||||||
|
|
||||||
|
service.syncLibrary(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verifyNoInteractions(steamApiService);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
|
void syncLibrary_ChangedPlaytime_StoresUnlocksAndThePlaytime() {
|
||||||
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
|
library(List.of(game(620L, 150)), List.of(schema(620L, 10)), List.of(unlocks(620L, 3, 120)));
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.SteamGame;
|
|||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
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;
|
||||||
|
import core_service.modules.library.domain.UserGameId;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
|
||||||
import core_service.modules.library.repo.SteamGameRepository;
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
import core_service.modules.library.repo.GameAchievementSchemaRepository;
|
||||||
@@ -104,6 +105,21 @@ class GameDetailServiceTest extends AbstractIntegrationTest {
|
|||||||
assertEquals(false, detail.hasAchievements());
|
assertEquals(false, detail.hasAchievements());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getGameDetail_HiddenGame_ShowsStoredDataAndAsksNobody() {
|
||||||
|
UserGame hidden = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
|
||||||
|
hidden.setHiddenAt(Instant.now());
|
||||||
|
userGameRepository.save(hidden);
|
||||||
|
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 1, true, Instant.now().minus(Duration.ofDays(31))));
|
||||||
|
schemaRepository.save(new GameAchievementSchema(620L, "A", "A", null, "icon", "gray", false));
|
||||||
|
|
||||||
|
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, STEAM_ID, 620L).orElseThrow();
|
||||||
|
|
||||||
|
org.mockito.Mockito.verifyNoInteractions(steamApiService, speedrunService, timeToBeatService, moodService);
|
||||||
|
assertEquals(true, detail.hidden());
|
||||||
|
assertEquals(1, detail.achievementsTotal());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void getGameDetail_GameAddedByHand_AsksForTheTimeToBeatAndMoodsAgain() {
|
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()));
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void report_StoresAReportForAnOwnedSteamGame() {
|
void report_StoresAReportForAnOwnedSteamGame() {
|
||||||
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of(620L));
|
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of(620L));
|
||||||
|
|
||||||
service.report(1L, 620L, ImageKind.COVER);
|
service.report(1L, 620L, ImageKind.COVER);
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ class ImageRepairServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
@Test
|
@Test
|
||||||
void report_DropsGamesTheUserDoesNotOwn() {
|
void report_DropsGamesTheUserDoesNotOwn() {
|
||||||
when(userGameRepository.findActiveAppids(1L, List.of(620L))).thenReturn(List.of());
|
when(userGameRepository.findListedAppids(1L, List.of(620L))).thenReturn(List.of());
|
||||||
|
|
||||||
service.report(1L, 620L, ImageKind.COVER);
|
service.report(1L, 620L, ImageKind.COVER);
|
||||||
|
|
||||||
|
|||||||
@@ -419,6 +419,31 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
verify(moodService).refresh(List.of(220L, -1025L));
|
verify(moodService).refresh(List.of(220L, -1025L));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_KeepsTheHiddenFlagAndUpdatesThePlaytime() {
|
||||||
|
UserGame hidden = owned(60);
|
||||||
|
Instant hiddenAt = Instant.parse("2026-09-01T10:00:00Z");
|
||||||
|
hidden.setHiddenAt(hiddenAt);
|
||||||
|
steamReturns(game(90, 30), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
assertEquals(hiddenAt, hidden.getHiddenAt());
|
||||||
|
assertEquals(90, hidden.getPlaytimeMinutes());
|
||||||
|
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_HiddenGamesGetNoTimeToBeatOrMoods() {
|
||||||
|
steamReturns(game(0, 0), STEAM_ID);
|
||||||
|
when(userGameRepository.findHiddenAppids(USER_ID)).thenReturn(List.of(220L));
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(timeToBeatService).refresh(List.of());
|
||||||
|
verify(moodService).refresh(List.of());
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runSync_GameWithoutName_GetsTheAppidAsName() {
|
void runSync_GameWithoutName_GetsTheAppidAsName() {
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
package core_service.modules.stats.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.SteamGameRepository;
|
||||||
|
import core_service.modules.library.repo.UserGameRepository;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.context.annotation.Import;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
@Import(StatsRepository.class)
|
||||||
|
class StatsRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private StatsRepository statsRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
steamGameRepository.saveAllAndFlush(List.of(new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||||
|
new SteamGame(1000L, "Playtest", null, Instant.now())));
|
||||||
|
UserGame hiddenPlaytest = new UserGame(1L, 1000L, 5000, Instant.now(), Instant.now());
|
||||||
|
hiddenPlaytest.setHiddenAt(Instant.now());
|
||||||
|
UserGame hiddenNeverPlayed = new UserGame(2L, 1000L, 0, Instant.now(), Instant.now());
|
||||||
|
hiddenNeverPlayed.setHiddenAt(Instant.now());
|
||||||
|
userGameRepository.saveAllAndFlush(List.of(new UserGame(1L, 220L, 300, Instant.now(), Instant.now()),
|
||||||
|
hiddenPlaytest, hiddenNeverPlayed));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenGames_LeaveEveryNumber() {
|
||||||
|
assertEquals(1, statsRepository.countDistinctGames());
|
||||||
|
assertEquals(1, statsRepository.countLibraryEntries());
|
||||||
|
assertEquals(1, statsRepository.countGamesPlayed());
|
||||||
|
assertEquals(300, statsRepository.sumPlaytimeMinutes());
|
||||||
|
assertEquals(1, statsRepository.countUsersWithLibrary());
|
||||||
|
assertEquals(1, statsRepository.biggestLibrary());
|
||||||
|
assertEquals(0, statsRepository.biggestBacklog());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void hiddenGames_LeaveTheTopLists() {
|
||||||
|
assertEquals(List.of(220L), appids(statsRepository.topOwnedGames(5)));
|
||||||
|
assertEquals(List.of(220L), appids(statsRepository.topPlayedGames(5)));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<Long> appids(List<Map<String, Object>> rows) {
|
||||||
|
return rows.stream().map(row -> ((Number) row.get("appid")).longValue()).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user