Public Access
feat(library): playtime history per day and a nightly sync
Each sync records the growth of playtime per game and day (Europe/Berlin). The first successful sync of a Steam account is only the baseline, tracked in user_steam_sync.history_steam_id, so a new or failed account never turns its whole playtime into one day. A nightly sync at 23:55 runs the recently used accounts one after the other. GET /api/library/activity returns minutes per day and the games of Steam's last two weeks.
This commit is contained in:
1 parent
afa2a7ea75
commit
ba1f1e6fe1
24 files changed
+607
-12
No files matched your search
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.configuration;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableScheduling
|
||||||
|
public class SchedulingConfig {
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package core_service.modules.library.api;
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
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.GameDto;
|
import core_service.modules.library.api.dto.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
@@ -39,6 +40,8 @@ public class LibraryController {
|
|||||||
|
|
||||||
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
|
private static final Set<Integer> ALLOWED_SIZES = Set.of(20, 40, 80);
|
||||||
private static final int DEFAULT_SIZE = 20;
|
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 Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
|
||||||
|
|
||||||
private final LibrarySyncService syncService;
|
private final LibrarySyncService syncService;
|
||||||
@@ -134,6 +137,13 @@ 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")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/activity")
|
||||||
|
public ResponseEntity<ActivityDto> getActivity(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam(defaultValue = "30") int days) {
|
||||||
|
int safeDays = Math.clamp(days, MIN_ACTIVITY_DAYS, MAX_ACTIVITY_DAYS);
|
||||||
|
return ResponseEntity.ok(queryService.getActivity(principal.getIdAsLong(), safeDays));
|
||||||
|
}
|
||||||
|
|
||||||
@GetMapping("/summary")
|
@GetMapping("/summary")
|
||||||
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
|
public ResponseEntity<LibrarySummaryDto> getSummary(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
@RequestParam(required = false) Long exclude) {
|
@RequestParam(required = false) Long exclude) {
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param days one entry per day, oldest first, also days without playtime
|
||||||
|
* @param recentGames up to 5 games by Steam's playtime of the last two weeks
|
||||||
|
*/
|
||||||
|
public record ActivityDto(
|
||||||
|
List<DayMinutes> days,
|
||||||
|
long totalMinutes,
|
||||||
|
List<RecentGameDto> recentGames
|
||||||
|
) {}
|
||||||
@@ -11,6 +11,7 @@ public record GameDetailDto(
|
|||||||
String name,
|
String name,
|
||||||
String imgIconUrl,
|
String imgIconUrl,
|
||||||
int playtimeMinutes,
|
int playtimeMinutes,
|
||||||
|
int playtime2Weeks,
|
||||||
Instant firstSeenAt,
|
Instant firstSeenAt,
|
||||||
Instant lastSeenAt,
|
Instant lastSeenAt,
|
||||||
GameStatus status,
|
GameStatus status,
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package core_service.modules.library.api.dto;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.GameStatus;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param playtime2Weeks Steam's playtime of the last two weeks, in minutes
|
||||||
|
*/
|
||||||
|
public record RecentGameDto(
|
||||||
|
Long appid,
|
||||||
|
String name,
|
||||||
|
int playtimeMinutes,
|
||||||
|
int playtime2Weeks,
|
||||||
|
GameStatus status
|
||||||
|
) {}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
public record DayMinutes(LocalDate day, long minutes) {}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.IdClass;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Written only through {@code PlaytimeDayRepository.addMinutes}.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "user_game_playtime_day")
|
||||||
|
@IdClass(PlaytimeDayId.class)
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
public class PlaytimeDay {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "user_id")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "day")
|
||||||
|
private LocalDate day;
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "appid")
|
||||||
|
private Long appid;
|
||||||
|
|
||||||
|
@Column(name = "minutes", nullable = false)
|
||||||
|
private int minutes;
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package core_service.modules.library.domain;
|
||||||
|
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode
|
||||||
|
public class PlaytimeDayId implements Serializable {
|
||||||
|
private Long userId;
|
||||||
|
private LocalDate day;
|
||||||
|
private Long appid;
|
||||||
|
}
|
||||||
@@ -36,6 +36,9 @@ public class UserGame {
|
|||||||
@Column(name = "playtime_minutes", nullable = false)
|
@Column(name = "playtime_minutes", nullable = false)
|
||||||
private int playtimeMinutes;
|
private int playtimeMinutes;
|
||||||
|
|
||||||
|
@Column(name = "playtime_2weeks", nullable = false)
|
||||||
|
private int playtime2Weeks;
|
||||||
|
|
||||||
@Column(name = "first_seen_at", nullable = false)
|
@Column(name = "first_seen_at", nullable = false)
|
||||||
private Instant firstSeenAt;
|
private Instant firstSeenAt;
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,12 @@ public class UserSteamSync {
|
|||||||
@Column(name = "last_sync_status", length = 16)
|
@Column(name = "last_sync_status", length = 16)
|
||||||
private Status lastSyncStatus;
|
private Status lastSyncStatus;
|
||||||
|
|
||||||
|
@Column(name = "history_steam_id", length = 32)
|
||||||
|
private String historySteamId;
|
||||||
|
|
||||||
|
@Column(name = "user_seen_at")
|
||||||
|
private Instant userSeenAt;
|
||||||
|
|
||||||
public UserSteamSync(Long userId, String steamId) {
|
public UserSteamSync(Long userId, String steamId) {
|
||||||
this.userId = userId;
|
this.userId = userId;
|
||||||
this.steamId = steamId;
|
this.steamId = steamId;
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
|
import core_service.modules.library.domain.PlaytimeDay;
|
||||||
|
import core_service.modules.library.domain.PlaytimeDayId;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) values (:userId, :day, :appid, :minutes) " +
|
||||||
|
"on conflict (user_id, day, appid) do update set minutes = user_game_playtime_day.minutes + excluded.minutes",
|
||||||
|
nativeQuery = true)
|
||||||
|
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
|
||||||
|
@Param("day") LocalDate day, @Param("minutes") int minutes);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only days with playtime; the caller fills the gaps.
|
||||||
|
*/
|
||||||
|
@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")
|
||||||
|
List<DayMinutes> minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from);
|
||||||
|
}
|
||||||
@@ -24,6 +24,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
|
|||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNull(Long userId);
|
long countByUserIdAndRemovedAtIsNull(Long userId);
|
||||||
|
|
||||||
|
List<UserGame> findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(
|
||||||
|
Long userId, int playtime2Weeks);
|
||||||
|
|
||||||
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
|
long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes);
|
||||||
|
|
||||||
@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 " +
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import org.springframework.data.jpa.repository.JpaRepository;
|
|||||||
import org.springframework.data.jpa.repository.Modifying;
|
import org.springframework.data.jpa.repository.Modifying;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
|
public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Long> {
|
||||||
|
|
||||||
@@ -40,5 +42,16 @@ public interface UserSteamSyncRepository extends JpaRepository<UserSteamSync, Lo
|
|||||||
void markFinished(@Param("userId") Long userId,
|
void markFinished(@Param("userId") Long userId,
|
||||||
@Param("status") String status,
|
@Param("status") String status,
|
||||||
@Param("now") Instant now);
|
@Param("now") Instant now);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Query(value = "UPDATE user_steam_sync SET history_steam_id = :steamId WHERE user_id = :userId", nativeQuery = true)
|
||||||
|
void markHistoryBaseline(@Param("userId") Long userId, @Param("steamId") String steamId);
|
||||||
|
|
||||||
|
@Modifying
|
||||||
|
@Transactional
|
||||||
|
@Query(value = "UPDATE user_steam_sync SET user_seen_at = :now WHERE user_id = :userId", nativeQuery = true)
|
||||||
|
void touchUserSeen(@Param("userId") Long userId, @Param("now") Instant now);
|
||||||
|
|
||||||
|
List<UserSteamSync> findByUserSeenAtAfter(Instant since);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,6 +108,7 @@ public class GameDetailService {
|
|||||||
meta != null ? meta.getName() : null,
|
meta != null ? meta.getName() : null,
|
||||||
meta != null ? meta.getImgIconUrl() : null,
|
meta != null ? meta.getImgIconUrl() : null,
|
||||||
userGame.getPlaytimeMinutes(),
|
userGame.getPlaytimeMinutes(),
|
||||||
|
userGame.getPlaytime2Weeks(),
|
||||||
userGame.getFirstSeenAt(),
|
userGame.getFirstSeenAt(),
|
||||||
userGame.getLastSeenAt(),
|
userGame.getLastSeenAt(),
|
||||||
userGame.getStatus(),
|
userGame.getStatus(),
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
package core_service.modules.library.service;
|
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.GameDto;
|
||||||
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
import core_service.modules.library.api.dto.LibrarySummaryDto;
|
||||||
|
import core_service.modules.library.api.dto.RecentGameDto;
|
||||||
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
import core_service.modules.library.domain.GameStatus;
|
import core_service.modules.library.domain.GameStatus;
|
||||||
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.UserSteamSync;
|
import core_service.modules.library.domain.UserSteamSync;
|
||||||
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
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;
|
||||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.data.domain.Pageable;
|
import org.springframework.data.domain.Pageable;
|
||||||
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.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.EnumMap;
|
import java.util.EnumMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -30,6 +37,10 @@ public class LibraryQueryService {
|
|||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
|
private final PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
|
private ZoneId zone;
|
||||||
|
|
||||||
public long countOwnedGames(Long userId) {
|
public long countOwnedGames(Long userId) {
|
||||||
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
|
return userGameRepository.countByUserIdAndRemovedAtIsNull(userId);
|
||||||
@@ -63,6 +74,34 @@ public class LibraryQueryService {
|
|||||||
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion);
|
return new LibrarySummaryDto(total, byStatus, neverPlayed, suggestion);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param days the number of days up to and including today
|
||||||
|
*/
|
||||||
|
public ActivityDto getActivity(Long userId, int days) {
|
||||||
|
LocalDate today = LocalDate.now(zone);
|
||||||
|
LocalDate from = today.minusDays(days - 1L);
|
||||||
|
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
|
||||||
|
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
|
||||||
|
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
|
||||||
|
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L)))
|
||||||
|
.toList();
|
||||||
|
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
|
||||||
|
|
||||||
|
List<UserGame> recent = userGameRepository
|
||||||
|
.findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0);
|
||||||
|
Map<Long, SteamGame> meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList())
|
||||||
|
.stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g));
|
||||||
|
List<RecentGameDto> recentGames = recent.stream()
|
||||||
|
.map(ug -> new RecentGameDto(
|
||||||
|
ug.getAppid(),
|
||||||
|
meta.containsKey(ug.getAppid()) ? meta.get(ug.getAppid()).getName() : null,
|
||||||
|
ug.getPlaytimeMinutes(),
|
||||||
|
ug.getPlaytime2Weeks(),
|
||||||
|
ug.getStatus()))
|
||||||
|
.toList();
|
||||||
|
return new ActivityDto(perDay, total, recentGames);
|
||||||
|
}
|
||||||
|
|
||||||
private static GameDto toDto(UserGame ug, SteamGame meta) {
|
private static GameDto toDto(UserGame ug, SteamGame meta) {
|
||||||
return new GameDto(
|
return new GameDto(
|
||||||
ug.getAppid(),
|
ug.getAppid(),
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import core_service.modules.library.repo.UserSteamSyncRepository;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -30,12 +32,16 @@ public class LibrarySyncService {
|
|||||||
@Value("${app.library.failed-retry-minutes:15}")
|
@Value("${app.library.failed-retry-minutes:15}")
|
||||||
private long failedRetryMinutes;
|
private long failedRetryMinutes;
|
||||||
|
|
||||||
|
@Value("${app.library.nightly-active-days:14}")
|
||||||
|
private long nightlyActiveDays;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Trigger sync only if data is older than TTL or was synced for another Steam ID.
|
* Trigger sync only if data is older than TTL or was synced for another Steam ID.
|
||||||
* Cheap gate check on caller thread, actual sync runs async via worker.
|
* Cheap gate check on caller thread, actual sync runs async via worker.
|
||||||
*/
|
*/
|
||||||
public void syncIfStale(Long userId, String steamId) {
|
public void syncIfStale(Long userId, String steamId) {
|
||||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||||
|
touchUserSeen(sync);
|
||||||
if (sync != null && isAlreadyRunning(sync)) {
|
if (sync != null && isAlreadyRunning(sync)) {
|
||||||
log.debug("Sync already running for user {}", userId);
|
log.debug("Sync already running for user {}", userId);
|
||||||
return;
|
return;
|
||||||
@@ -46,11 +52,25 @@ public class LibrarySyncService {
|
|||||||
worker.runSync(userId, steamId);
|
worker.runSync(userId, steamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Syncs every known Steam account shortly before midnight, so the playtime history gets the growth of each
|
||||||
|
* day even when the user does not open the app. Accounts are synced one after the other on the scheduler
|
||||||
|
* thread; accounts without a user request in the last {@code nightly-active-days} are skipped.
|
||||||
|
*/
|
||||||
|
@Scheduled(cron = "${app.library.nightly-sync-cron:0 55 23 * * *}", zone = "${app.library.zone:Europe/Berlin}")
|
||||||
|
public void syncAllNightly() {
|
||||||
|
Instant activeSince = Instant.now().minus(Duration.ofDays(nightlyActiveDays));
|
||||||
|
List<UserSteamSync> accounts = userSteamSyncRepository.findByUserSeenAtAfter(activeSince);
|
||||||
|
log.info("Nightly sync for {} Steam accounts", accounts.size());
|
||||||
|
accounts.forEach(sync -> worker.runSyncNow(sync.getUserId(), sync.getSteamId()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
|
* User-triggered sync. Throws SyncCooldownException if recently synced or running.
|
||||||
*/
|
*/
|
||||||
public void forceSync(Long userId, String steamId) {
|
public void forceSync(Long userId, String steamId) {
|
||||||
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null);
|
||||||
|
touchUserSeen(sync);
|
||||||
if (sync != null && sync.getLastSyncedAt() != null) {
|
if (sync != null && sync.getLastSyncedAt() != null) {
|
||||||
long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds();
|
long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds();
|
||||||
if (sinceLast < forceSyncCooldownSeconds) {
|
if (sinceLast < forceSyncCooldownSeconds) {
|
||||||
@@ -63,6 +83,14 @@ public class LibrarySyncService {
|
|||||||
worker.runSync(userId, steamId);
|
worker.runSync(userId, steamId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// At most one write per user and hour; the nightly sync only needs to know "used recently".
|
||||||
|
private void touchUserSeen(UserSteamSync sync) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (sync != null && (sync.getUserSeenAt() == null || sync.getUserSeenAt().isBefore(now.minus(Duration.ofHours(1))))) {
|
||||||
|
userSteamSyncRepository.touchUserSeen(sync.getUserId(), now);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private boolean isStale(UserSteamSync sync) {
|
private boolean isStale(UserSteamSync sync) {
|
||||||
if (sync.getLastSyncedAt() == null) return true;
|
if (sync.getLastSyncedAt() == null) return true;
|
||||||
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED
|
Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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.domain.UserGameId;
|
||||||
import core_service.modules.library.domain.UserSteamSync;
|
import core_service.modules.library.domain.UserSteamSync;
|
||||||
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
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;
|
||||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||||
@@ -18,6 +19,8 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
import core_service.modules.library.domain.UserSteamSync.Status;
|
import core_service.modules.library.domain.UserSteamSync.Status;
|
||||||
@@ -36,17 +39,34 @@ public class LibrarySyncWorker {
|
|||||||
private final SteamGameRepository steamGameRepository;
|
private final SteamGameRepository steamGameRepository;
|
||||||
private final UserGameRepository userGameRepository;
|
private final UserGameRepository userGameRepository;
|
||||||
private final UserSteamSyncRepository userSteamSyncRepository;
|
private final UserSteamSyncRepository userSteamSyncRepository;
|
||||||
|
private final PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
@Value("${app.library.running-stale-minutes:5}")
|
@Value("${app.library.running-stale-minutes:5}")
|
||||||
private long runningStaleMinutes;
|
private long runningStaleMinutes;
|
||||||
|
|
||||||
|
@Value("${app.library.zone:Europe/Berlin}")
|
||||||
|
private ZoneId zone;
|
||||||
|
|
||||||
@Async
|
@Async
|
||||||
@Transactional
|
@Transactional
|
||||||
public void runSync(Long userId, String steamId) {
|
public void runSync(Long userId, String steamId) {
|
||||||
|
sync(userId, steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same as {@link #runSync}, but on the caller's thread. The nightly job uses it to sync one account after
|
||||||
|
* the other instead of starting all at once against the Steam rate limit and the connection pool.
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void runSyncNow(Long userId, String steamId) {
|
||||||
|
sync(userId, steamId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void sync(Long userId, String steamId) {
|
||||||
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
|
Instant runningStaleBefore = Instant.now().minus(Duration.ofMinutes(runningStaleMinutes));
|
||||||
String previousSteamId = userSteamSyncRepository.findById(userId)
|
UserSteamSync before = userSteamSyncRepository.findById(userId).orElse(null);
|
||||||
.map(UserSteamSync::getSteamId)
|
String previousSteamId = before != null ? before.getSteamId() : null;
|
||||||
.orElse(null);
|
String historySteamId = before != null ? before.getHistorySteamId() : null;
|
||||||
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
|
int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore);
|
||||||
if (claimed == 0) {
|
if (claimed == 0) {
|
||||||
log.info("Sync skipped for user {} — another run already in flight", userId);
|
log.info("Sync skipped for user {} — another run already in flight", userId);
|
||||||
@@ -56,7 +76,11 @@ public class LibrarySyncWorker {
|
|||||||
Instant syncStartedAt = Instant.now();
|
Instant syncStartedAt = Instant.now();
|
||||||
Status finalStatus = Status.FAILED;
|
Status finalStatus = Status.FAILED;
|
||||||
try {
|
try {
|
||||||
if (previousSteamId != null && !previousSteamId.equals(steamId)) {
|
boolean steamIdChanged = previousSteamId != null && !previousSteamId.equals(steamId);
|
||||||
|
// Growth needs a successful earlier sync of this account; else all its playtime would count as today.
|
||||||
|
boolean recordPlaytime = steamId.equals(historySteamId);
|
||||||
|
LocalDate today = LocalDate.ofInstant(syncStartedAt, zone);
|
||||||
|
if (steamIdChanged) {
|
||||||
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
|
// The old games belong to the previous Steam account. The upserts below restore the games this account also owns.
|
||||||
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||||
}
|
}
|
||||||
@@ -71,13 +95,14 @@ public class LibrarySyncWorker {
|
|||||||
for (SteamOwnedGamesResponse.Game g : games) {
|
for (SteamOwnedGamesResponse.Game g : games) {
|
||||||
if (g.appid() == null) continue;
|
if (g.appid() == null) continue;
|
||||||
upsertGame(g, syncStartedAt);
|
upsertGame(g, syncStartedAt);
|
||||||
upsertUserGame(userId, g, syncStartedAt);
|
upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null);
|
||||||
}
|
}
|
||||||
|
|
||||||
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now());
|
||||||
if (removed > 0) {
|
if (removed > 0) {
|
||||||
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
|
log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed);
|
||||||
}
|
}
|
||||||
|
userSteamSyncRepository.markHistoryBaseline(userId, steamId);
|
||||||
finalStatus = Status.OK;
|
finalStatus = Status.OK;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.error("Sync failed for user {}", userId, e);
|
log.error("Sync failed for user {}", userId, e);
|
||||||
@@ -98,16 +123,28 @@ public class LibrarySyncWorker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now) {
|
/**
|
||||||
|
* @param playedOn the day that gets the playtime growth, null to record none
|
||||||
|
*/
|
||||||
|
private void upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
|
||||||
Long appid = g.appid().longValue();
|
Long appid = g.appid().longValue();
|
||||||
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
|
UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null);
|
||||||
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
|
int playtime = g.playtimeForever() != null ? g.playtimeForever() : 0;
|
||||||
|
int previousPlaytime = existing != null ? existing.getPlaytimeMinutes() : 0;
|
||||||
|
int playtime2Weeks = g.playtime2Weeks() != null ? g.playtime2Weeks() : 0;
|
||||||
if (existing == null) {
|
if (existing == null) {
|
||||||
userGameRepository.save(new UserGame(userId, appid, playtime, now, now));
|
UserGame created = new UserGame(userId, appid, playtime, now, now);
|
||||||
|
created.setPlaytime2Weeks(playtime2Weeks);
|
||||||
|
userGameRepository.save(created);
|
||||||
} else {
|
} else {
|
||||||
existing.setPlaytimeMinutes(playtime);
|
existing.setPlaytimeMinutes(playtime);
|
||||||
|
existing.setPlaytime2Weeks(playtime2Weeks);
|
||||||
existing.setLastSeenAt(now);
|
existing.setLastSeenAt(now);
|
||||||
existing.setRemovedAt(null);
|
existing.setRemovedAt(null);
|
||||||
}
|
}
|
||||||
|
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted.
|
||||||
|
if (playedOn != null && playtime > previousPlaytime) {
|
||||||
|
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+2
-1
@@ -21,6 +21,7 @@ public record SteamOwnedGamesResponse(
|
|||||||
@JsonProperty("name") String name,
|
@JsonProperty("name") String name,
|
||||||
@JsonProperty("playtime_forever") Integer playtimeForever,
|
@JsonProperty("playtime_forever") Integer playtimeForever,
|
||||||
@JsonProperty("img_icon_url") String imgIconUrl,
|
@JsonProperty("img_icon_url") String imgIconUrl,
|
||||||
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats
|
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
|
||||||
|
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
|
||||||
) {}
|
) {}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Minutes played per user, game and day (Europe/Berlin), as the growth of playtime_forever between two syncs.
|
||||||
|
-- The first sync of a Steam account is only the baseline and writes no rows.
|
||||||
|
CREATE TABLE user_game_playtime_day (
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
day DATE NOT NULL,
|
||||||
|
appid BIGINT NOT NULL,
|
||||||
|
minutes INTEGER NOT NULL CHECK (minutes > 0),
|
||||||
|
PRIMARY KEY (user_id, day, appid)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- The Steam account whose playtime is the baseline for the history: set after a successful sync only.
|
||||||
|
-- Growth is recorded only while it equals steam_id, so a failed first sync of a new account cannot turn
|
||||||
|
-- that account's whole playtime into one day.
|
||||||
|
ALTER TABLE user_steam_sync ADD COLUMN history_steam_id VARCHAR(32);
|
||||||
|
|
||||||
|
-- Last request of the user. The nightly sync skips accounts nobody used for a while, e.g. after the user
|
||||||
|
-- removed the Steam ID (Core does not learn about that).
|
||||||
|
ALTER TABLE user_steam_sync ADD COLUMN user_seen_at TIMESTAMPTZ;
|
||||||
|
|
||||||
|
-- Steam's own "last two weeks" playtime, updated on every sync.
|
||||||
|
ALTER TABLE user_game ADD COLUMN playtime_2weeks INTEGER NOT NULL DEFAULT 0;
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package core_service.modules.library.api;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.SteamGame;
|
||||||
|
import core_service.modules.library.domain.UserGame;
|
||||||
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
|
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.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
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 LibraryControllerActivityTest extends AbstractIntegrationTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private MockMvc mockMvc;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SteamGameRepository steamGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserGameRepository userGameRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JdbcTemplate jdbc;
|
||||||
|
|
||||||
|
private final LocalDate today = LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void seed() {
|
||||||
|
userGameRepository.deleteAll();
|
||||||
|
jdbc.update("delete from user_game_playtime_day");
|
||||||
|
steamGameRepository.saveAll(List.of(
|
||||||
|
new SteamGame(220L, "Half-Life 2", null, Instant.now()),
|
||||||
|
new SteamGame(620L, "Portal 2", null, Instant.now()),
|
||||||
|
new SteamGame(700L, "Dota 2", null, Instant.now())));
|
||||||
|
UserGame hl2 = new UserGame(USER_ID, 220L, 300, Instant.now(), Instant.now());
|
||||||
|
hl2.setPlaytime2Weeks(30);
|
||||||
|
UserGame portal = new UserGame(USER_ID, 620L, 900, Instant.now(), Instant.now());
|
||||||
|
portal.setPlaytime2Weeks(120);
|
||||||
|
userGameRepository.saveAll(List.of(hl2, portal, new UserGame(USER_ID, 700L, 0, Instant.now(), Instant.now())));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activity_HasEveryDayWithZeroesAndTheRecentGames() throws Exception {
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 30);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 20);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, today.minusDays(6), 40);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, today.minusDays(7), 999);
|
||||||
|
|
||||||
|
mockMvc.perform(get("/api/library/activity").param("days", "7"))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.days", hasSize(7)))
|
||||||
|
.andExpect(jsonPath("$.days[0].day").value(today.minusDays(6).toString()))
|
||||||
|
.andExpect(jsonPath("$.days[0].minutes").value(40))
|
||||||
|
.andExpect(jsonPath("$.days[1].minutes").value(0))
|
||||||
|
.andExpect(jsonPath("$.days[6].day").value(today.toString()))
|
||||||
|
.andExpect(jsonPath("$.days[6].minutes").value(50))
|
||||||
|
.andExpect(jsonPath("$.totalMinutes").value(90))
|
||||||
|
.andExpect(jsonPath("$.recentGames", hasSize(2)))
|
||||||
|
.andExpect(jsonPath("$.recentGames[0].appid").value(620))
|
||||||
|
.andExpect(jsonPath("$.recentGames[0].name").value("Portal 2"))
|
||||||
|
.andExpect(jsonPath("$.recentGames[0].playtime2Weeks").value(120))
|
||||||
|
.andExpect(jsonPath("$.recentGames[1].appid").value(220));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void activity_DefaultsTo30DaysAndClampsTheRange() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/library/activity")).andExpect(jsonPath("$.days", hasSize(30)));
|
||||||
|
mockMvc.perform(get("/api/library/activity").param("days", "1000")).andExpect(jsonPath("$.days", hasSize(90)));
|
||||||
|
mockMvc.perform(get("/api/library/activity").param("days", "0")).andExpect(jsonPath("$.days", hasSize(7)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package core_service.modules.library.repo;
|
||||||
|
|
||||||
|
import core_service.modules.library.domain.DayMinutes;
|
||||||
|
import core_service.shared.test.AbstractPersistenceTest;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
|
class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
|
||||||
|
|
||||||
|
private static final long USER_ID = 1L;
|
||||||
|
private static final LocalDate DAY = LocalDate.of(2026, 9, 20);
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void addMinutes_SameGameAndDay_AddsUp() {
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 45);
|
||||||
|
|
||||||
|
assertEquals(List.of(new DayMinutes(DAY, 75)), playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() {
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 30);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY, 20);
|
||||||
|
playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(2), 10);
|
||||||
|
playtimeDayRepository.addMinutes(2L, 220L, DAY, 500);
|
||||||
|
|
||||||
|
assertEquals(List.of(new DayMinutes(DAY, 50), new DayMinutes(DAY.plusDays(2), 10)),
|
||||||
|
playtimeDayRepository.minutesPerDay(USER_ID, DAY));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,11 +11,15 @@ import org.springframework.test.util.ReflectionTestUtils;
|
|||||||
|
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.anyString;
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
import static org.mockito.Mockito.verify;
|
import static org.mockito.Mockito.verify;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
@@ -101,4 +105,35 @@ class LibrarySyncServiceTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
verify(worker, never()).runSync(anyLong(), anyString());
|
verify(worker, never()).runSync(anyLong(), anyString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncAllNightly_SyncsRecentlyUsedAccountsOneAfterTheOther() {
|
||||||
|
when(userSteamSyncRepository.findByUserSeenAtAfter(any())).thenReturn(List.of(
|
||||||
|
new UserSteamSync(1L, "76561198000000001"),
|
||||||
|
new UserSteamSync(2L, "76561198005679790")));
|
||||||
|
|
||||||
|
syncService.syncAllNightly();
|
||||||
|
|
||||||
|
verify(worker).runSyncNow(1L, "76561198000000001");
|
||||||
|
verify(worker).runSyncNow(2L, "76561198005679790");
|
||||||
|
verify(worker, never()).runSync(anyLong(), anyString());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void syncIfStale_MarksTheUserAsSeenAtMostHourly() {
|
||||||
|
UserSteamSync seenLongAgo = new UserSteamSync(USER_ID, STEAM_ID);
|
||||||
|
seenLongAgo.setLastSyncStatus(UserSteamSync.Status.OK);
|
||||||
|
seenLongAgo.setLastSyncedAt(Instant.now());
|
||||||
|
seenLongAgo.setUserSeenAt(Instant.now().minus(Duration.ofDays(2)));
|
||||||
|
UserSteamSync seenJustNow = new UserSteamSync(USER_ID, STEAM_ID);
|
||||||
|
seenJustNow.setLastSyncStatus(UserSteamSync.Status.OK);
|
||||||
|
seenJustNow.setLastSyncedAt(Instant.now());
|
||||||
|
seenJustNow.setUserSeenAt(Instant.now());
|
||||||
|
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(seenLongAgo), Optional.of(seenJustNow));
|
||||||
|
|
||||||
|
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||||
|
syncService.syncIfStale(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(userSteamSyncRepository, times(1)).touchUserSeen(eq(USER_ID), any());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -4,25 +4,31 @@ 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;
|
||||||
import core_service.modules.library.domain.UserSteamSync;
|
import core_service.modules.library.domain.UserSteamSync;
|
||||||
|
import core_service.modules.library.repo.PlaytimeDayRepository;
|
||||||
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;
|
||||||
import core_service.modules.library.repo.UserSteamSyncRepository;
|
import core_service.modules.library.repo.UserSteamSyncRepository;
|
||||||
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse;
|
||||||
import core_service.modules.steam_adapter.service.SteamApiService;
|
import core_service.modules.steam_adapter.service.SteamApiService;
|
||||||
import core_service.shared.test.AbstractUnitTest;
|
import core_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.params.ParameterizedTest;
|
import org.junit.jupiter.params.ParameterizedTest;
|
||||||
import org.junit.jupiter.params.provider.CsvSource;
|
import org.junit.jupiter.params.provider.CsvSource;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
|
import org.springframework.test.util.ReflectionTestUtils;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
|
import java.time.ZoneId;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyInt;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.never;
|
import static org.mockito.Mockito.never;
|
||||||
@@ -33,6 +39,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
|
|
||||||
private static final long USER_ID = 1L;
|
private static final long USER_ID = 1L;
|
||||||
private static final String STEAM_ID = "76561198000000001";
|
private static final String STEAM_ID = "76561198000000001";
|
||||||
|
private static final String OTHER_STEAM_ID = "76561198005679790";
|
||||||
|
|
||||||
|
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
|
||||||
|
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate today() {
|
||||||
|
return LocalDate.now(ZoneId.of("Europe/Berlin"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param previousSteamId the account of the last successful sync, null for the first sync of the user
|
||||||
|
*/
|
||||||
|
private void steamReturns(SteamOwnedGamesResponse.Game game, String previousSteamId) {
|
||||||
|
if (previousSteamId != null) {
|
||||||
|
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(Optional.of(syncRow(previousSteamId, previousSteamId)));
|
||||||
|
}
|
||||||
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(game));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSteamSync syncRow(String steamId, String historySteamId) {
|
||||||
|
UserSteamSync row = new UserSteamSync(USER_ID, steamId);
|
||||||
|
row.setHistorySteamId(historySteamId);
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
|
||||||
|
private UserGame owned(int playtime) {
|
||||||
|
UserGame existing = new UserGame(USER_ID, 220L, playtime, Instant.EPOCH, Instant.EPOCH);
|
||||||
|
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private SteamApiService steamApiService;
|
private SteamApiService steamApiService;
|
||||||
@@ -46,9 +84,17 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private UserSteamSyncRepository userSteamSyncRepository;
|
private UserSteamSyncRepository userSteamSyncRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PlaytimeDayRepository playtimeDayRepository;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
private LibrarySyncWorker worker;
|
private LibrarySyncWorker worker;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
|
void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() {
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
@@ -90,7 +136,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
void runSync_WithGames_MarksOk() {
|
void runSync_WithGames_MarksOk() {
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 0, "icon", true)));
|
game(0, 0)));
|
||||||
|
|
||||||
worker.runSync(USER_ID, STEAM_ID);
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
@@ -103,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
|
void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) {
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true)));
|
game(playtime, 0)));
|
||||||
|
|
||||||
worker.runSync(USER_ID, STEAM_ID);
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
@@ -119,11 +165,94 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
|
|||||||
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
when(userGameRepository.findById(new UserGameId(USER_ID, 220L))).thenReturn(Optional.of(existing));
|
||||||
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
|
||||||
new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 90, "icon", true)));
|
game(90, 0)));
|
||||||
|
|
||||||
worker.runSync(USER_ID, STEAM_ID);
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
assertEquals(GameStatus.DROPPED, existing.getStatus());
|
assertEquals(GameStatus.DROPPED, existing.getStatus());
|
||||||
assertEquals(90, existing.getPlaytimeMinutes());
|
assertEquals(90, existing.getPlaytimeMinutes());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
|
||||||
|
owned(100);
|
||||||
|
steamReturns(game(160, 60), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_PlaytimeUnchangedOrLower_RecordsNothing() {
|
||||||
|
owned(100);
|
||||||
|
steamReturns(game(90, 0), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_NewGameAfterEarlierSyncs_RecordsItsWholePlaytime() {
|
||||||
|
steamReturns(game(45, 45), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_FirstSync_IsOnlyTheBaseline() {
|
||||||
|
steamReturns(game(5000, 60), null);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_NewSteamId_IsOnlyTheBaseline() {
|
||||||
|
owned(100);
|
||||||
|
steamReturns(game(5000, 60), OTHER_STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_FailedFirstSyncOfNewSteamId_NextSyncIsStillOnlyTheBaseline() {
|
||||||
|
when(userSteamSyncRepository.findById(USER_ID)).thenReturn(
|
||||||
|
Optional.of(syncRow(OTHER_STEAM_ID, OTHER_STEAM_ID)),
|
||||||
|
Optional.of(syncRow(STEAM_ID, OTHER_STEAM_ID)));
|
||||||
|
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1);
|
||||||
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(), List.of(game(5000, 60)));
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt());
|
||||||
|
verify(userSteamSyncRepository).markHistoryBaseline(USER_ID, STEAM_ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_EmptyResult_KeepsTheOldBaseline() {
|
||||||
|
steamReturns(game(0, 0), STEAM_ID);
|
||||||
|
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of());
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
verify(userSteamSyncRepository, never()).markHistoryBaseline(anyLong(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void runSync_StoresRecentPlaytime() {
|
||||||
|
UserGame existing = owned(100);
|
||||||
|
steamReturns(game(160, 75), STEAM_ID);
|
||||||
|
|
||||||
|
worker.runSync(USER_ID, STEAM_ID);
|
||||||
|
|
||||||
|
assertEquals(75, existing.getPlaytime2Weeks());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -11,7 +11,8 @@ import org.testcontainers.containers.PostgreSQLContainer;
|
|||||||
* Full Spring context with Testcontainers.
|
* Full Spring context with Testcontainers.
|
||||||
*/
|
*/
|
||||||
@Tag("IntegrationTest")
|
@Tag("IntegrationTest")
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
|
// The nightly sync would call Steam with the dev key if a test ran at 23:55.
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = "app.library.nightly-sync-cron=-")
|
||||||
@ActiveProfiles("dev")
|
@ActiveProfiles("dev")
|
||||||
public abstract class AbstractIntegrationTest {
|
public abstract class AbstractIntegrationTest {
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user