Public Access
Compare commits
2
Commits
0fa2a37f47
...
88aae0101f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
88aae0101f | ||
|
|
01552ecd89 |
No files matched your search
@@ -52,6 +52,7 @@ jobs:
|
||||
BRANCH="$(echo "$BRANCH" | tr '[:upper:]' '[:lower:]')"
|
||||
echo "branch=$BRANCH" >> "$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
|
||||
uses: docker/setup-buildx-action@v3
|
||||
@@ -69,6 +70,9 @@ jobs:
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
build-args: |
|
||||
GIT_SHA=${{ github.sha }}
|
||||
BUILD_TIME=${{ steps.prep.outputs.build_time }}
|
||||
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 }}-${{ steps.prep.outputs.sha }}
|
||||
|
||||
@@ -25,6 +25,11 @@ FROM eclipse-temurin:25-jre-alpine
|
||||
|
||||
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 8090
|
||||
|
||||
|
||||
@@ -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/public/profiles/library` | profile ticket |
|
||||
| `GET` | `/api/stats/public` | public |
|
||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||
|
||||
## Tech stack
|
||||
|
||||
@@ -118,6 +119,7 @@ flowchart LR
|
||||
LibApi["api"] --> LibService["service"] --> LibRepo["repo"]
|
||||
end
|
||||
Stats["stats"]
|
||||
Version["version"]
|
||||
Steam["steam_adapter"]
|
||||
Igdb["igdb_adapter"]
|
||||
Speedrun["speedrun_adapter"]
|
||||
@@ -140,6 +142,7 @@ flowchart LR
|
||||
|---|---|
|
||||
| `library` | Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data |
|
||||
| `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 |
|
||||
| `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 |
|
||||
@@ -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.
|
||||
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.
|
||||
|
||||
@@ -32,7 +32,8 @@ public class SecurityConfig {
|
||||
"/actuator/health", // Docker health check; details are off
|
||||
"/error",
|
||||
"/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;
|
||||
|
||||
@@ -14,10 +14,16 @@ import java.util.List;
|
||||
|
||||
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
|
||||
@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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
// 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) {}
|
||||
@@ -38,6 +38,10 @@ security:
|
||||
public-key: ${JWT_PUBLIC_KEY:}
|
||||
jwks-url: ${JWT_JWKS_URL:}
|
||||
app:
|
||||
build:
|
||||
# Filled from the CI run by the Dockerfile.
|
||||
commit: ${APP_BUILD_COMMIT:dev}
|
||||
time: ${APP_BUILD_TIME:}
|
||||
cors:
|
||||
allowed-origins: "https://tbp.andreas-dahm.eu"
|
||||
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())
|
||||
.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));
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user