diff --git a/readme.md b/readme.md index ac7f442..b7ea9b1 100644 --- a/readme.md +++ b/readme.md @@ -169,7 +169,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` 3. `LibrarySyncWorker` runs in the background. `user_steam_sync` lets only one run per user start. 4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer - owns. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no + owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings + no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no games, the library stays as it is. 5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam games and manual games. Hidden games only get their playtime updated. diff --git a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java index 7e5ee04..5953374 100644 --- a/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java +++ b/src/main/java/core_service/modules/library/repo/PlaytimeDayRepository.java @@ -14,10 +14,16 @@ import java.util.List; public interface PlaytimeDayRepository extends JpaRepository { + /** + * At most 24 hours per game and day, also over several syncs: after a gap between syncs, the playtime of the + * whole gap lands on one day. + */ @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", + @Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) " + + "values (:userId, :day, :appid, least(:minutes, 1440)) " + + "on conflict (user_id, day, appid) do update " + + "set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)", nativeQuery = true) void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid, @Param("day") LocalDate day, @Param("minutes") int minutes); 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 9b8cba2..ef54cc2 100644 --- a/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java +++ b/src/main/java/core_service/modules/library/service/LibrarySyncWorker.java @@ -191,7 +191,7 @@ public class LibrarySyncWorker { /** * @param playedOn the day that gets the playtime growth, null to record none - * @return true when playtime growth was recorded + * @return true when the game was played since the last sync */ private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) { Long appid = g.appid().longValue(); @@ -213,11 +213,16 @@ public class LibrarySyncWorker { existing.setSource(GameSource.STEAM); existing.setPlatform(null); } - // A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. - if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) { - playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime); - return true; + if (playedOn == null || takesOverManualGame) { + return false; } - return false; + // A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. + int growth = playtime - previousPlaytime; + // Growth beyond Steam's last two weeks is older playtime, e.g. of a game this sync sees for the first time. + int minutesToday = Math.min(growth, playtime2Weeks); + if (minutesToday > 0) { + playtimeDayRepository.addMinutes(userId, appid, playedOn, minutesToday); + } + return existing == null ? minutesToday > 0 : growth > 0; } } diff --git a/src/main/resources/db/migration/V18__drop_old_playtime_of_new_games.sql b/src/main/resources/db/migration/V18__drop_old_playtime_of_new_games.sql new file mode 100644 index 0000000..20f01b8 --- /dev/null +++ b/src/main/resources/db/migration/V18__drop_old_playtime_of_new_games.sql @@ -0,0 +1,17 @@ +-- Until now, a game a sync saw for the first time put its whole playtime_forever on that day, e.g. 40 hours on one +-- day for an app that skip_unvetted_apps=0 brought in. Drop these rows: the minutes such a game really got on that +-- day cannot be told apart from its older playtime. +-- The games of the first sync are the baseline: they share one first_seen_at, and their rows on that day are real. +WITH baseline AS ( + SELECT user_id, mode() WITHIN GROUP (ORDER BY first_seen_at) AS first_seen_at + FROM user_game + GROUP BY user_id +) +DELETE FROM user_game_playtime_day p +USING user_game ug, baseline b +WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND b.user_id = ug.user_id + AND (ug.first_seen_at AT TIME ZONE 'Europe/Berlin')::date = p.day + AND ug.first_seen_at <> b.first_seen_at; + +-- A gap between syncs put several days on one day; one game now gets at most one day per day. +UPDATE user_game_playtime_day SET minutes = 1440 WHERE minutes > 1440; diff --git a/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java index b3e135c..11453df 100644 --- a/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java +++ b/src/test/java/core_service/modules/library/repo/PlaytimeDayRepositoryTest.java @@ -35,6 +35,16 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest { assertEquals(List.of(new DayMinutes(DAY, 75)), playtimeDayRepository.minutesPerDay(USER_ID, DAY)); } + @Test + void addMinutes_OneGameGetsAtMostOneDay() { + playtimeDayRepository.addMinutes(USER_ID, 220L, DAY, 2378); + playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(1), 1400); + playtimeDayRepository.addMinutes(USER_ID, 620L, DAY.plusDays(1), 180); + + assertEquals(List.of(new DayMinutes(DAY, 1440), new DayMinutes(DAY.plusDays(1), 1440)), + playtimeDayRepository.minutesPerDay(USER_ID, DAY)); + } + @Test void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() { playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999); 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 74d5bbe..f240b09 100644 --- a/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java +++ b/src/test/java/core_service/modules/library/service/LibrarySyncWorkerTest.java @@ -290,6 +290,46 @@ class LibrarySyncWorkerTest extends AbstractUnitTest { verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45); } + @Test + void runSync_NewGameWithOldPlaytime_RecordsOnlyTheLastTwoWeeks() { + steamReturns(game(2378, 30), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 30); + } + + @Test + void runSync_NewGameNotPlayedForTwoWeeks_RecordsNothingAndLeavesThePileAlone() { + steamReturns(game(2378, 0), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); + verify(userGameRepository, never()).playFromPileIfUntouched(anyLong(), any()); + } + + @Test + void runSync_GrowthOverManyDays_RecordsAtMostTheLastTwoWeeks() { + owned(100); + steamReturns(game(1000, 60), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 60); + } + + @Test + void runSync_GrowthOlderThanTwoWeeks_RecordsNothingButTakesTheGameFromThePile() { + owned(100); + steamReturns(game(160, 0), STEAM_ID); + + worker.runSync(USER_ID, STEAM_ID); + + verify(playtimeDayRepository, never()).addMinutes(anyLong(), anyLong(), any(), anyInt()); + verify(userGameRepository).playFromPileIfUntouched(USER_ID, List.of(220L)); + } + @Test void runSync_FirstSync_IsOnlyTheBaseline() { steamReturns(game(5000, 60), null);