Compare commits

..
2 Commits
Author SHA1 Message Date
lobo 88aae0101f feat(version): answer the build commit and time
Build, Test & Push Core Service / docker (push) Successful in 1m20s
Build, Test & Push Core Service / quality-check (push) Successful in 57s
GET /api/version is public. CI passes GIT_SHA and BUILD_TIME as Docker build args; a local build answers dev.
2026-09-27 10:27:01 +02:00
lobo 01552ecd89 fix(sync): keep old playtime off the day a game shows up
A game the sync saw for the first time put its whole playtime on that
day (40 h "longest gaming day" after skip_unvetted_apps=0). Growth now
counts at most Steam's last two weeks, and one game gets at most 24 h
per day. V18 drops the bad rows and caps old ones.
2026-09-27 10:00:53 +02:00
14 changed files with 166 additions and 10 deletions

No files matched your search

+4
View File
@@ -52,6 +52,7 @@ jobs:
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')" BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
echo "branch=$BRANCH" >> "$GITHUB_OUTPUT" echo "branch=$BRANCH" >> "$GITHUB_OUTPUT"
echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT" echo "sha=${GITHUB_SHA::12}" >> "$GITHUB_OUTPUT"
echo "build_time=$(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_OUTPUT"
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
@@ -69,6 +70,9 @@ jobs:
with: with:
context: . context: .
push: true push: true
build-args: |
GIT_SHA=${{ github.sha }}
BUILD_TIME=${{ steps.prep.outputs.build_time }}
tags: | tags: |
git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }} git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }}
git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }} git.andreas-dahm.eu/lobo_corp/tbp-core-service:${{ steps.prep.outputs.branch }}-${{ steps.prep.outputs.sha }}
+5
View File
@@ -25,6 +25,11 @@ FROM eclipse-temurin:25-jre-alpine
WORKDIR /app WORKDIR /app
# Passed by the CI workflow; shown by GET /api/version
ARG GIT_SHA=dev
ARG BUILD_TIME=
ENV APP_BUILD_COMMIT=$GIT_SHA APP_BUILD_TIME=$BUILD_TIME
# Expose the port your Spring Boot app runs on # Expose the port your Spring Boot app runs on
EXPOSE 8090 EXPOSE 8090
+5 -1
View File
@@ -93,6 +93,7 @@ accepted only by `GET /api/public/profiles/library` in the `X-Profile-Ticket` he
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT | | `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket | | `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/stats/public` | public | | `GET` | `/api/stats/public` | public |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
## Tech stack ## Tech stack
@@ -118,6 +119,7 @@ flowchart LR
LibApi["api"] --> LibService["service"] --> LibRepo["repo"] LibApi["api"] --> LibService["service"] --> LibRepo["repo"]
end end
Stats["stats"] Stats["stats"]
Version["version"]
Steam["steam_adapter"] Steam["steam_adapter"]
Igdb["igdb_adapter"] Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"] Speedrun["speedrun_adapter"]
@@ -140,6 +142,7 @@ flowchart LR
|---|---| |---|---|
| `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data | | `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page | | `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures | | `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
| `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search | | `igdb_adapter` | IGDB client (Twitch OAuth): time to beat, game tags for moods, release years, game search |
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs | | `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
@@ -169,7 +172,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. 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 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 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. 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 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. games and manual games. Hidden games only get their playtime updated.
@@ -32,7 +32,8 @@ public class SecurityConfig {
"/actuator/health", // Docker health check; details are off "/actuator/health", // Docker health check; details are off
"/error", "/error",
"/api/stats/public", "/api/stats/public",
"/api/public/**" // Public profiles; PublicProfileController checks the ticket "/api/public/**", // Public profiles; PublicProfileController checks the ticket
"/api/version" // Build of the running image, shown in the settings
}; };
private final OncePerRequestFilter jwtAuthFilter; private final OncePerRequestFilter jwtAuthFilter;
@@ -14,10 +14,16 @@ import java.util.List;
public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> { public interface PlaytimeDayRepository extends JpaRepository<PlaytimeDay, PlaytimeDayId> {
/**
* 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 @Modifying
@Transactional @Transactional
@Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) values (:userId, :day, :appid, :minutes) " + @Query(value = "insert into user_game_playtime_day (user_id, day, appid, minutes) " +
"on conflict (user_id, day, appid) do update set minutes = user_game_playtime_day.minutes + excluded.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) nativeQuery = true)
void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid, void addMinutes(@Param("userId") Long userId, @Param("appid") Long appid,
@Param("day") LocalDate day, @Param("minutes") int minutes); @Param("day") LocalDate day, @Param("minutes") int minutes);
@@ -191,7 +191,7 @@ public class LibrarySyncWorker {
/** /**
* @param playedOn the day that gets the playtime growth, null to record none * @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) { private boolean upsertUserGame(Long userId, SteamOwnedGamesResponse.Game g, Instant now, LocalDate playedOn) {
Long appid = g.appid().longValue(); Long appid = g.appid().longValue();
@@ -213,11 +213,16 @@ public class LibrarySyncWorker {
existing.setSource(GameSource.STEAM); existing.setSource(GameSource.STEAM);
existing.setPlatform(null); existing.setPlatform(null);
} }
// A Steam recount can lower playtime_forever; the growth back to the old value counts again. Accepted. if (playedOn == null || takesOverManualGame) {
if (playedOn != null && !takesOverManualGame && playtime > previousPlaytime) {
playtimeDayRepository.addMinutes(userId, appid, playedOn, playtime - previousPlaytime);
return true;
}
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;
}
} }
@@ -0,0 +1,22 @@
package core_service.modules.version.api;
import core_service.modules.version.api.dto.VersionDto;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class VersionController {
private final VersionDto version;
public VersionController(@Value("${app.build.commit}") String commit, @Value("${app.build.time}") String builtAt) {
this.version = new VersionDto(commit.isBlank() ? "dev" : commit, builtAt.isBlank() ? null : builtAt);
}
@GetMapping("/api/version")
public ResponseEntity<VersionDto> getVersion() {
return ResponseEntity.ok(version);
}
}
@@ -0,0 +1,7 @@
package core_service.modules.version.api.dto;
/**
* @param commit full git hash the image was built from, "dev" outside the CI build
* @param builtAt ISO-8601 UTC build time, null outside the CI build
*/
public record VersionDto(String commit, String builtAt) {}
+4
View File
@@ -38,6 +38,10 @@ security:
public-key: ${JWT_PUBLIC_KEY:} public-key: ${JWT_PUBLIC_KEY:}
jwks-url: ${JWT_JWKS_URL:} jwks-url: ${JWT_JWKS_URL:}
app: app:
build:
# Filled from the CI run by the Dockerfile.
commit: ${APP_BUILD_COMMIT:dev}
time: ${APP_BUILD_TIME:}
cors: cors:
allowed-origins: "https://tbp.andreas-dahm.eu" allowed-origins: "https://tbp.andreas-dahm.eu"
steam: steam:
@@ -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;
@@ -90,4 +90,11 @@ class PublicEndpointsTest {
mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized()) mockMvc.perform(get("/api/public/profiles/library")).andExpect(status().isUnauthorized())
.andExpect(jsonPath("$.code").value("INVALID_TICKET")); .andExpect(jsonPath("$.code").value("INVALID_TICKET"));
} }
@Test
void versionIsPublic() throws Exception {
mockMvc.perform(get("/api/version"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.commit").isString());
}
} }
@@ -35,6 +35,16 @@ class PlaytimeDayRepositoryTest extends AbstractPersistenceTest {
assertEquals(List.of(new DayMinutes(DAY, 75)), playtimeDayRepository.minutesPerDay(USER_ID, DAY)); 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 @Test
void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() { void minutesPerDay_SumsAllGamesOfTheUserFromTheGivenDay() {
playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999); playtimeDayRepository.addMinutes(USER_ID, 220L, DAY.minusDays(1), 999);
@@ -290,6 +290,46 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 45); 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 @Test
void runSync_FirstSync_IsOnlyTheBaseline() { void runSync_FirstSync_IsOnlyTheBaseline() {
steamReturns(game(5000, 60), null); steamReturns(game(5000, 60), null);
@@ -0,0 +1,24 @@
package core_service.modules.version.api;
import core_service.modules.version.api.dto.VersionDto;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
class VersionControllerTest extends AbstractUnitTest {
@Test
void answersTheBuildOfTheImage() {
VersionDto version = new VersionController("4ec47f1aa0b1", "2026-09-27T08:05:00Z").getVersion().getBody();
assertEquals(new VersionDto("4ec47f1aa0b1", "2026-09-27T08:05:00Z"), version);
}
@Test
void emptyBuildArgsCountAsLocalBuild() {
VersionDto version = new VersionController("", "").getVersion().getBody();
assertEquals(new VersionDto("dev", null), version);
}
}