From c2d17d0bc79c6a5cc0ab18feaf6ccec1f19d46c9 Mon Sep 17 00:00:00 2001 From: lobo Date: Sat, 26 Sep 2026 12:27:58 +0200 Subject: [PATCH] feat(library): hide games from lists and stats --- readme.md | 7 +- .../library/api/LibraryController.java | 19 +- .../library/api/dto/GameDetailDto.java | 4 +- .../modules/library/api/dto/GameDto.java | 4 +- .../library/api/dto/HiddenUpdateDto.java | 5 + .../modules/library/domain/UserGame.java | 4 + .../library/repo/PlaytimeDayRepository.java | 8 +- .../library/repo/UserGameRepository.java | 44 +++-- .../service/AchievementSyncService.java | 2 +- .../library/service/BacklogService.java | 24 +++ .../library/service/GameDetailService.java | 23 ++- .../library/service/ImageRepairService.java | 4 +- .../library/service/LibraryQueryService.java | 21 ++- .../library/service/LibrarySyncWorker.java | 2 + .../library/service/ManualGameService.java | 2 + .../modules/stats/repo/StatsRepository.java | 18 +- .../db/migration/V12__hidden_games.sql | 5 + .../api/LibraryControllerBrokenImageTest.java | 14 ++ .../api/LibraryControllerHiddenTest.java | 173 ++++++++++++++++++ .../library/api/ManualGameControllerTest.java | 13 ++ .../repo/PlaytimeDayRepositoryTest.java | 30 +++ .../library/repo/UserGameRepositoryTest.java | 69 ++++++- .../service/AchievementSyncServiceTest.java | 11 ++ .../service/GameDetailServiceTest.java | 16 ++ .../service/ImageRepairServiceTest.java | 4 +- .../service/LibrarySyncWorkerTest.java | 25 +++ .../stats/repo/StatsRepositoryTest.java | 64 +++++++ 27 files changed, 559 insertions(+), 56 deletions(-) create mode 100644 src/main/java/core_service/modules/library/api/dto/HiddenUpdateDto.java create mode 100644 src/main/resources/db/migration/V12__hidden_games.sql create mode 100644 src/test/java/core_service/modules/library/api/LibraryControllerHiddenTest.java create mode 100644 src/test/java/core_service/modules/stats/repo/StatsRepositoryTest.java diff --git a/readme.md b/readme.md index ea57f36..aab8898 100644 --- a/readme.md +++ b/readme.md @@ -31,6 +31,10 @@ To Be Played is split into three repositories: 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" 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. - **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 @@ -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 | | `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}/hidden` (`{hidden}`; `409 MANUAL_GAME` for a game added by hand) | JWT | | `POST` | `/api/library/sync` | JWT | | `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | 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 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. + and manual games. Hidden games only get their playtime updated. 6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only) in the background. diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index efb8802..b65f397 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -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.BrokenImageReport; 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.PageDto; import core_service.modules.library.api.dto.PickerDto; @@ -88,7 +89,8 @@ public class LibraryController { @RequestParam(defaultValue = "playtimeMinutes,desc") String sort, @RequestParam(required = false) GameStatus status, @RequestParam(required = false) String q, - @RequestParam(required = false) Integer maxMinutes + @RequestParam(required = false) Integer maxMinutes, + @RequestParam(defaultValue = "false") boolean hidden ) { Long userId = principal.getIdAsLong(); if (principal.steamId() != null) { @@ -98,7 +100,7 @@ public class LibraryController { int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE; int safePage = Math.max(0, page); 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)); } @@ -162,6 +164,19 @@ public class LibraryController { .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") public ResponseEntity updateStatus(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("appid") Long appid, diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 5f37579..83cc4c6 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -31,7 +31,9 @@ public record GameDetailDto( String platform, String imageUrl, // null until a check finds a speedrun.com game with verified runs - String speedrunUrl + String speedrunUrl, + // see GameDto#hidden + boolean hidden ) { /** diff --git a/src/main/java/core_service/modules/library/api/dto/GameDto.java b/src/main/java/core_service/modules/library/api/dto/GameDto.java index d67980e..4d8d708 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDto.java @@ -10,6 +10,7 @@ import core_service.modules.library.domain.GameStatus; * @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 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( Long appid, @@ -24,5 +25,6 @@ public record GameDto( Integer achievementsTotal, GameSource source, String platform, - String imageUrl + String imageUrl, + boolean hidden ) {} diff --git a/src/main/java/core_service/modules/library/api/dto/HiddenUpdateDto.java b/src/main/java/core_service/modules/library/api/dto/HiddenUpdateDto.java new file mode 100644 index 0000000..634a45a --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/HiddenUpdateDto.java @@ -0,0 +1,5 @@ +package core_service.modules.library.api.dto; + +import jakarta.validation.constraints.NotNull; + +public record HiddenUpdateDto(@NotNull Boolean hidden) {} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 1c7b74e..8e4dd23 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -48,6 +48,10 @@ public class UserGame { @Column(name = "removed_at") 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) @Column(name = "status", nullable = false, length = 16) private GameStatus status; diff --git a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java index 10914c9..7e5ee04 100644 --- a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java +++ b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java @@ -23,9 +23,13 @@ public interface PlaytimeDayRepository extends JpaRepository