Public Access
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:
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)));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user