feat(library): picker for what to play next and a status-only update

GET /api/library/picker returns random backlog games for a time budget, HIGH priority first. PUT /games/{appid}/status changes only the status, so the picker does not need the game detail (and its Steam calls) to keep rating, notes and priority.
This commit is contained in:
lobo committed 2026-09-25 09:29:15 +02:00
1 parent 0a5186782f
commit 86db6f7167
9 files changed
+314 -3

No files matched your search

@@ -5,6 +5,8 @@ import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PageDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.StatusUpdateDto;
import core_service.modules.library.api.dto.SteamProfileDto;
import core_service.modules.library.api.dto.SyncStatusDto;
import core_service.modules.library.api.dto.WishlistDto;
@@ -33,7 +35,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
@RestController
@@ -45,6 +49,8 @@ public class LibraryController {
private static final int DEFAULT_SIZE = 20;
private static final int MIN_ACTIVITY_DAYS = 7;
private static final int MAX_ACTIVITY_DAYS = 90;
private static final int MAX_PICKER_GAMES = 10;
private static final int MAX_PICKER_EXCLUDES = 100;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private final LibrarySyncService syncService;
@@ -142,6 +148,16 @@ public class LibraryController {
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@PutMapping("/games/{appid}/status")
public ResponseEntity<?> updateStatus(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid,
@Valid @RequestBody StatusUpdateDto update) {
return backlogService.updateStatus(principal.getIdAsLong(), appid, update.status())
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library")));
}
@GetMapping("/activity")
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "30") int days) {
@@ -160,6 +176,18 @@ public class LibraryController {
return ResponseEntity.ok(queryService.getSummary(principal.getIdAsLong(), exclude));
}
@GetMapping("/picker")
public ResponseEntity<PickerDto> getPicker(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(required = false) Integer maxMinutes,
@RequestParam(required = false) List<Long> exclude,
@RequestParam(defaultValue = "3") int count) {
List<Long> excluded = exclude == null ? List.of()
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
return ResponseEntity.ok(queryService.pickBacklogGames(principal.getIdAsLong(), budget, excluded, safeCount));
}
@GetMapping("/sync-status")
public ResponseEntity<SyncStatusDto> getSyncStatus(@AuthenticationPrincipal UserPrincipal principal) {
return queryService.getSyncStatus(principal.getIdAsLong())
@@ -0,0 +1,5 @@
package core_service.modules.library.api.dto;
import java.util.List;
public record PickerDto(List<GameDto> games) {}
@@ -0,0 +1,6 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
import jakarta.validation.constraints.NotNull;
public record StatusUpdateDto(@NotNull GameStatus status) {}
@@ -14,6 +14,7 @@ import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Locale;
@@ -46,6 +47,30 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
"and appid <> :excludeAppid order by random() limit 1", nativeQuery = true)
Optional<UserGame> findRandomBacklogGame(@Param("userId") Long userId, @Param("excludeAppid") long excludeAppid);
/**
* 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 excludeAppids never picked; may be empty
*/
default List<UserGame> findPickerGames(Long userId, Integer maxMinutes, Collection<Long> excludeAppids, int count) {
List<Long> excluded = new ArrayList<>(excludeAppids);
// "not in ()" is invalid SQL, and no appid is -1
excluded.add(-1L);
return queryPickerGames(userId, maxMinutes, 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)) " +
"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("excludeAppids") Collection<Long> excludeAppids,
@Param("count") int count);
/**
* @param status null for every status
* @param namePattern a {@link #containsPattern} result, null for every name
@@ -1,6 +1,7 @@
package core_service.modules.library.service;
import core_service.modules.library.api.dto.BacklogEntryDto;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.UserGameRepository;
@@ -32,6 +33,21 @@ public class BacklogService {
});
}
/**
* Changes only the status and keeps rating, notes and priority.
*
* @return empty when the game is not in the user's active library
*/
@Transactional
public Optional<BacklogEntryDto> updateStatus(Long userId, Long appid, GameStatus status) {
return userGameRepository.findById(new UserGameId(userId, appid))
.filter(game -> game.getRemovedAt() == null)
.map(game -> {
game.setStatus(status);
return toDto(game);
});
}
private static BacklogEntryDto toDto(UserGame game) {
return new BacklogEntryDto(game.getStatus(), game.getRating(), game.getNotes(), game.getPriority());
}
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
@@ -21,6 +22,7 @@ import core_service.modules.library.repo.WishlistGameRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.domain.Pageable;
@@ -64,12 +66,21 @@ public class LibraryQueryService {
UserGameRepository.querySort(pageable.getSort()));
Page<UserGame> page = userGameRepository.findOwnedGames(
userId, status, UserGameRepository.containsPattern(nameQuery), maxMinutes, stable);
List<Long> appids = page.getContent().stream().map(UserGame::getAppid).toList();
List<GameDto> games = toDtos(page.getContent());
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)));
}
private List<GameDto> toDtos(List<UserGame> games) {
List<Long> appids = games.stream().map(UserGame::getAppid).toList();
Map<Long, SteamGame> meta = steamGameRepository.findAllById(appids).stream()
.collect(Collectors.toMap(SteamGame::getAppid, g -> g));
Map<Long, GameTimeToBeat> times = gameTimeToBeatRepository.findAllById(appids).stream()
.collect(Collectors.toMap(GameTimeToBeat::getAppid, t -> t));
return page.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid())));
return games.stream().map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()))).toList();
}
/**
@@ -1,5 +1,6 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
@@ -135,4 +136,53 @@ class LibraryControllerBacklogTest extends AbstractIntegrationTest {
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
@Test
void updateStatus_KeepsRatingNotesAndPriority() throws Exception {
UserGame game = stored();
game.setRating(4);
game.setNotes("co-op with Tom");
game.setPriority(GamePriority.HIGH);
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.status").value("PLAYING"))
.andExpect(jsonPath("$.notes").value("co-op with Tom"));
UserGame updated = stored();
assertEquals(GameStatus.PLAYING, updated.getStatus());
assertEquals(4, updated.getRating());
assertEquals("co-op with Tom", updated.getNotes());
assertEquals(GamePriority.HIGH, updated.getPriority());
}
@Test
void updateStatus_MissingStatus_Is400() throws Exception {
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{}"))
.andExpect(status().isBadRequest());
assertEquals(GameStatus.BACKLOG, stored().getStatus());
}
@Test
void updateStatus_RemovedGame_Is404() throws Exception {
UserGame game = stored();
game.setRemovedAt(Instant.now());
userGameRepository.save(game);
mockMvc.perform(put("/api/library/games/220/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void updateStatus_GameNotOwned_Is404() throws Exception {
mockMvc.perform(put("/api/library/games/999/status").contentType(MediaType.APPLICATION_JSON)
.content("{\"status\":\"PLAYING\"}"))
.andExpect(status().isNotFound());
}
}
@@ -0,0 +1,166 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.GamePriority;
import core_service.modules.library.domain.GameStatus;
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.GameTimeToBeatRepository;
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.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.List;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
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 LibraryControllerPickerTest extends AbstractIntegrationTest {
private static final long USER_ID = 1L;
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private GameTimeToBeatRepository gameTimeToBeatRepository;
@BeforeEach
void clean() {
userGameRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
}
private void game(long appid, GameStatus status, GamePriority priority, Integer normallyMinutes) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
UserGame game = new UserGame(USER_ID, appid, 0, Instant.now(), Instant.now());
game.setStatus(status);
game.setPriority(priority);
userGameRepository.save(game);
if (normallyMinutes != null) {
gameTimeToBeatRepository.save(new GameTimeToBeat(appid, null, normallyMinutes, null, Instant.now()));
}
}
@Test
void picker_EmptyBacklog_ReturnsNoGames() throws Exception {
game(10L, GameStatus.PLAYING, null, 60);
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games", hasSize(0)));
}
@Test
void picker_WithoutBudget_PicksOnlyBacklogGamesIncludingUnknownLength() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, null);
game(30L, GameStatus.FINISHED, null, 60);
game(40L, GameStatus.DROPPED, null, 60);
mockMvc.perform(get("/api/library/picker"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)))
.andExpect(jsonPath("$.games[?(@.appid == 10)].timeToBeatMinutes").value(60));
}
@Test
void picker_WithBudget_SkipsLongerAndUnknownGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, 301);
game(30L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "300"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_ZeroBudgetMeansAnyLength() throws Exception {
game(10L, GameStatus.BACKLOG, null, 300);
game(20L, GameStatus.BACKLOG, null, null);
mockMvc.perform(get("/api/library/picker").param("maxMinutes", "0"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10, 20)));
}
@Test
void picker_SkipsExcludedGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 60);
game(30L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("exclude", "10", "30"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(20)));
}
@Test
void picker_FillsSlotsByPriorityFirst() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, GamePriority.LOW, 60);
game(30L, GameStatus.BACKLOG, GamePriority.HIGH, 60);
game(40L, GameStatus.BACKLOG, GamePriority.MEDIUM, 60);
game(50L, GameStatus.BACKLOG, null, 60);
mockMvc.perform(get("/api/library/picker").param("count", "2"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(30, 40)))
.andExpect(jsonPath("$.games[0].appid").value(30));
}
@Test
void picker_DefaultsToThreeAndCapsTheCount() throws Exception {
for (long appid = 1; appid <= 12; appid++) {
game(appid, GameStatus.BACKLOG, null, 60);
}
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(3)));
mockMvc.perform(get("/api/library/picker").param("count", "500"))
.andExpect(jsonPath("$.games", hasSize(10)));
mockMvc.perform(get("/api/library/picker").param("count", "0"))
.andExpect(jsonPath("$.games", hasSize(1)));
}
@Test
void picker_SkipsRemovedGames() throws Exception {
game(10L, GameStatus.BACKLOG, null, 60);
game(20L, GameStatus.BACKLOG, null, 60);
UserGame removed = userGameRepository.findById(new UserGameId(USER_ID, 20L)).orElseThrow();
removed.setRemovedAt(Instant.now());
userGameRepository.save(removed);
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games[*].appid", containsInAnyOrder(10)));
}
@Test
void picker_OnlyShowsTheCallersGames() throws Exception {
steamGameRepository.save(new SteamGame(99L, "Other", null, Instant.now()));
userGameRepository.save(new UserGame(2L, 99L, 0, Instant.now(), Instant.now()));
mockMvc.perform(get("/api/library/picker"))
.andExpect(jsonPath("$.games", hasSize(0)));
}
}