diff --git a/src/main/java/core_service/configuration/SchedulingConfig.java b/src/main/java/core_service/configuration/SchedulingConfig.java new file mode 100644 index 0000000..1564be4 --- /dev/null +++ b/src/main/java/core_service/configuration/SchedulingConfig.java @@ -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 { +} diff --git a/src/main/java/core_service/modules/library/api/LibraryController.java b/src/main/java/core_service/modules/library/api/LibraryController.java index 6722a02..c911cfc 100644 --- a/src/main/java/core_service/modules/library/api/LibraryController.java +++ b/src/main/java/core_service/modules/library/api/LibraryController.java @@ -1,5 +1,6 @@ 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.GameDto; import core_service.modules.library.api.dto.LibrarySummaryDto; @@ -39,6 +40,8 @@ public class LibraryController { private static final Set ALLOWED_SIZES = Set.of(20, 40, 80); 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 SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority"); private final LibrarySyncService syncService; @@ -134,6 +137,13 @@ public class LibraryController { .body(Map.of("code", "GAME_NOT_FOUND", "message", "Game not in user library"))); } + @GetMapping("/activity") + public ResponseEntity 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") public ResponseEntity getSummary(@AuthenticationPrincipal UserPrincipal principal, @RequestParam(required = false) Long exclude) { diff --git a/src/main/java/core_service/modules/library/api/dto/ActivityDto.java b/src/main/java/core_service/modules/library/api/dto/ActivityDto.java new file mode 100644 index 0000000..a7474e8 --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/ActivityDto.java @@ -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 days, + long totalMinutes, + List recentGames +) {} diff --git a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java index 33af0c0..8476d0b 100644 --- a/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java +++ b/src/main/java/core_service/modules/library/api/dto/GameDetailDto.java @@ -11,6 +11,7 @@ public record GameDetailDto( String name, String imgIconUrl, int playtimeMinutes, + int playtime2Weeks, Instant firstSeenAt, Instant lastSeenAt, GameStatus status, diff --git a/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java b/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java new file mode 100644 index 0000000..1517c6c --- /dev/null +++ b/src/main/java/core_service/modules/library/api/dto/RecentGameDto.java @@ -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 +) {} diff --git a/src/main/java/core_service/modules/library/domain/DayMinutes.java b/src/main/java/core_service/modules/library/domain/DayMinutes.java new file mode 100644 index 0000000..50577a0 --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/DayMinutes.java @@ -0,0 +1,5 @@ +package core_service.modules.library.domain; + +import java.time.LocalDate; + +public record DayMinutes(LocalDate day, long minutes) {} diff --git a/src/main/java/core_service/modules/library/domain/PlaytimeDay.java b/src/main/java/core_service/modules/library/domain/PlaytimeDay.java new file mode 100644 index 0000000..5929d0f --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PlaytimeDay.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/PlaytimeDayId.java b/src/main/java/core_service/modules/library/domain/PlaytimeDayId.java new file mode 100644 index 0000000..2cbed7e --- /dev/null +++ b/src/main/java/core_service/modules/library/domain/PlaytimeDayId.java @@ -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; +} diff --git a/src/main/java/core_service/modules/library/domain/UserGame.java b/src/main/java/core_service/modules/library/domain/UserGame.java index 53b0ac6..0e12cc5 100644 --- a/src/main/java/core_service/modules/library/domain/UserGame.java +++ b/src/main/java/core_service/modules/library/domain/UserGame.java @@ -36,6 +36,9 @@ public class UserGame { @Column(name = "playtime_minutes", nullable = false) private int playtimeMinutes; + @Column(name = "playtime_2weeks", nullable = false) + private int playtime2Weeks; + @Column(name = "first_seen_at", nullable = false) private Instant firstSeenAt; diff --git a/src/main/java/core_service/modules/library/domain/UserSteamSync.java b/src/main/java/core_service/modules/library/domain/UserSteamSync.java index b6cc1e5..c329e79 100644 --- a/src/main/java/core_service/modules/library/domain/UserSteamSync.java +++ b/src/main/java/core_service/modules/library/domain/UserSteamSync.java @@ -36,6 +36,12 @@ public class UserSteamSync { @Column(name = "last_sync_status", length = 16) 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) { this.userId = userId; this.steamId = steamId; diff --git a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java new file mode 100644 index 0000000..10914c9 --- /dev/null +++ b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java @@ -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 { + + @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 minutesPerDay(@Param("userId") Long userId, @Param("from") LocalDate from); +} diff --git a/src/main/java/core_service/modules/library/repo/UserGameRepository.java b/src/main/java/core_service/modules/library/repo/UserGameRepository.java index 59432a5..c752507 100644 --- a/src/main/java/core_service/modules/library/repo/UserGameRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserGameRepository.java @@ -24,6 +24,9 @@ public interface UserGameRepository extends JpaRepository long countByUserIdAndRemovedAtIsNull(Long userId); + List findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc( + Long userId, int playtime2Weeks); + long countByUserIdAndRemovedAtIsNullAndPlaytimeMinutes(Long userId, int playtimeMinutes); @Query("select new core_service.modules.library.domain.StatusCount(ug.status, count(ug)) from UserGame ug " + diff --git a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java index f8205f5..96a073e 100644 --- a/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java +++ b/src/main/java/core_service/modules/library/repo/UserSteamSyncRepository.java @@ -5,8 +5,10 @@ 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.Instant; +import java.util.List; public interface UserSteamSyncRepository extends JpaRepository { @@ -40,5 +42,16 @@ public interface UserSteamSyncRepository extends JpaRepository findByUserSeenAtAfter(Instant since); } diff --git a/src/main/java/core_service/modules/library/service/GameDetailService.java b/src/main/java/core_service/modules/library/service/GameDetailService.java index 78a8717..5ad8cc8 100644 --- a/src/main/java/core_service/modules/library/service/GameDetailService.java +++ b/src/main/java/core_service/modules/library/service/GameDetailService.java @@ -108,6 +108,7 @@ public class GameDetailService { meta != null ? meta.getName() : null, meta != null ? meta.getImgIconUrl() : null, userGame.getPlaytimeMinutes(), + userGame.getPlaytime2Weeks(), userGame.getFirstSeenAt(), userGame.getLastSeenAt(), userGame.getStatus(), diff --git a/src/main/java/core_service/modules/library/service/LibraryQueryService.java b/src/main/java/core_service/modules/library/service/LibraryQueryService.java index a7754b7..3276fa8 100644 --- a/src/main/java/core_service/modules/library/service/LibraryQueryService.java +++ b/src/main/java/core_service/modules/library/service/LibraryQueryService.java @@ -1,21 +1,28 @@ 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.RecentGameDto; +import core_service.modules.library.domain.DayMinutes; import core_service.modules.library.domain.GameStatus; import core_service.modules.library.domain.SteamGame; import core_service.modules.library.domain.UserGame; import core_service.modules.library.domain.UserSteamSync; +import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.EnumMap; import java.util.List; import java.util.Map; @@ -30,6 +37,10 @@ public class LibraryQueryService { private final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; private final SteamGameRepository steamGameRepository; + private final PlaytimeDayRepository playtimeDayRepository; + + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; public long countOwnedGames(Long userId) { return userGameRepository.countByUserIdAndRemovedAtIsNull(userId); @@ -63,6 +74,34 @@ public class LibraryQueryService { 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 played = playtimeDayRepository.minutesPerDay(userId, from).stream() + .collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes)); + List 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 recent = userGameRepository + .findTop5ByUserIdAndRemovedAtIsNullAndPlaytime2WeeksGreaterThanOrderByPlaytime2WeeksDesc(userId, 0); + Map meta = steamGameRepository.findAllById(recent.stream().map(UserGame::getAppid).toList()) + .stream().collect(Collectors.toMap(SteamGame::getAppid, g -> g)); + List 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) { return new GameDto( ug.getAppid(), diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncService.java b/src/main/java/core_service/modules/library/service/LibrarySyncService.java index ba478d2..3d867e8 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncService.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncService.java @@ -5,10 +5,12 @@ import core_service.modules.library.repo.UserSteamSyncRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; import java.time.Duration; import java.time.Instant; +import java.util.List; @Service @RequiredArgsConstructor @@ -30,12 +32,16 @@ public class LibrarySyncService { @Value("${app.library.failed-retry-minutes:15}") 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. * Cheap gate check on caller thread, actual sync runs async via worker. */ public void syncIfStale(Long userId, String steamId) { UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); + touchUserSeen(sync); if (sync != null && isAlreadyRunning(sync)) { log.debug("Sync already running for user {}", userId); return; @@ -46,11 +52,25 @@ public class LibrarySyncService { 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 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. */ public void forceSync(Long userId, String steamId) { UserSteamSync sync = userSteamSyncRepository.findById(userId).orElse(null); + touchUserSeen(sync); if (sync != null && sync.getLastSyncedAt() != null) { long sinceLast = Duration.between(sync.getLastSyncedAt(), Instant.now()).toSeconds(); if (sinceLast < forceSyncCooldownSeconds) { @@ -63,6 +83,14 @@ public class LibrarySyncService { 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) { if (sync.getLastSyncedAt() == null) return true; Duration maxAge = sync.getLastSyncStatus() == UserSteamSync.Status.FAILED diff --git a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java index 295ea07..f612f3f 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -4,6 +4,7 @@ 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.domain.UserSteamSync; +import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; @@ -18,6 +19,8 @@ import org.springframework.transaction.annotation.Transactional; import java.time.Duration; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import core_service.modules.library.domain.UserSteamSync.Status; @@ -36,17 +39,34 @@ public class LibrarySyncWorker { private final SteamGameRepository steamGameRepository; private final UserGameRepository userGameRepository; private final UserSteamSyncRepository userSteamSyncRepository; + private final PlaytimeDayRepository playtimeDayRepository; @Value("${app.library.running-stale-minutes:5}") private long runningStaleMinutes; + @Value("${app.library.zone:Europe/Berlin}") + private ZoneId zone; + @Async @Transactional 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)); - String previousSteamId = userSteamSyncRepository.findById(userId) - .map(UserSteamSync::getSteamId) - .orElse(null); + UserSteamSync before = userSteamSyncRepository.findById(userId).orElse(null); + String previousSteamId = before != null ? before.getSteamId() : null; + String historySteamId = before != null ? before.getHistorySteamId() : null; int claimed = userSteamSyncRepository.tryClaimRun(userId, steamId, runningStaleBefore); if (claimed == 0) { log.info("Sync skipped for user {} — another run already in flight", userId); @@ -56,7 +76,11 @@ public class LibrarySyncWorker { Instant syncStartedAt = Instant.now(); Status finalStatus = Status.FAILED; 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. userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); } @@ -71,13 +95,14 @@ public class LibrarySyncWorker { for (SteamOwnedGamesResponse.Game g : games) { if (g.appid() == null) continue; upsertGame(g, syncStartedAt); - upsertUserGame(userId, g, syncStartedAt); + upsertUserGame(userId, g, syncStartedAt, recordPlaytime ? today : null); } int removed = userGameRepository.softDeleteMissingSince(userId, syncStartedAt, Instant.now()); if (removed > 0) { log.info("Sync user={} soft-deleted {} games no longer owned", userId, removed); } + userSteamSyncRepository.markHistoryBaseline(userId, steamId); finalStatus = Status.OK; } catch (Exception 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(); UserGame existing = userGameRepository.findById(new UserGameId(userId, appid)).orElse(null); 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) { - 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 { existing.setPlaytimeMinutes(playtime); + existing.setPlaytime2Weeks(playtime2Weeks); existing.setLastSeenAt(now); 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); + } } } diff --git a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java index a4baf4a..691e74c 100644 --- a/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java +++ b/src/main/java/core_service/modules/steam_adapter/api/dto/response/SteamOwnedGamesResponse.java @@ -21,6 +21,7 @@ public record SteamOwnedGamesResponse( @JsonProperty("name") String name, @JsonProperty("playtime_forever") Integer playtimeForever, @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 ) {} } diff --git a/src/main/resources/db/migration/V4__playtime_history.sql b/src/main/resources/db/migration/V4__playtime_history.sql new file mode 100644 index 0000000..49e4e1b --- /dev/null +++ b/src/main/resources/db/migration/V4__playtime_history.sql @@ -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; diff --git a/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java new file mode 100644 index 0000000..a95a41e --- /dev/null +++ b/src/test/java/core_service/modules/library/api/LibraryControllerActivityTest.java @@ -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))); + } +} diff --git a/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java new file mode 100644 index 0000000..2af611e --- /dev/null +++ b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java @@ -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)); + } +} diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java index 888d8b8..5510805 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncServiceTest.java @@ -11,11 +11,15 @@ import org.springframework.test.util.ReflectionTestUtils; import java.time.Duration; import java.time.Instant; +import java.util.List; import java.util.Optional; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -101,4 +105,35 @@ class LibrarySyncServiceTest extends AbstractUnitTest { 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()); + } } diff --git a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java index 10bf7f1..43d7e4f 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -4,25 +4,31 @@ 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.domain.UserSteamSync; +import core_service.modules.library.repo.PlaytimeDayRepository; import core_service.modules.library.repo.SteamGameRepository; import core_service.modules.library.repo.UserGameRepository; import core_service.modules.library.repo.UserSteamSyncRepository; import core_service.modules.steam_adapter.api.dto.response.SteamOwnedGamesResponse; import core_service.modules.steam_adapter.service.SteamApiService; import core_service.shared.test.AbstractUnitTest; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.CsvSource; import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; +import org.springframework.test.util.ReflectionTestUtils; import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneId; import java.util.List; import java.util.Optional; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anyLong; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.never; @@ -33,6 +39,38 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { private static final long USER_ID = 1L; 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 private SteamApiService steamApiService; @@ -46,9 +84,17 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { @Mock private UserSteamSyncRepository userSteamSyncRepository; + @Mock + private PlaytimeDayRepository playtimeDayRepository; + @InjectMocks private LibrarySyncWorker worker; + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(worker, "zone", ZoneId.of("Europe/Berlin")); + } + @Test void runSync_EmptySteamResult_MarksFailedAndKeepsLibrary() { when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); @@ -90,7 +136,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { void runSync_WithGames_MarksOk() { when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); 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); @@ -103,7 +149,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { void runSync_NewGame_GetsStatusFromPlaytime(int playtime, GameStatus expected) { when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any())).thenReturn(1); 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); @@ -119,11 +165,94 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { 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(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); assertEquals(GameStatus.DROPPED, existing.getStatus()); 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()); + } } diff --git a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java index a2ffb6a..f0e4589 100644 --- a/src/test/java/core_service/shared/test/AbstractIntegrationTest.java +++ b/src/test/java/core_service/shared/test/AbstractIntegrationTest.java @@ -11,7 +11,8 @@ import org.testcontainers.containers.PostgreSQLContainer; * Full Spring context with Testcontainers. */ @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") public abstract class AbstractIntegrationTest {