Log every status change with a trigger, so no code path can miss it. Store IGDB genres with the moods. Write a pile snapshot per user each night. The sync moves an untouched pile game with new playtime to PLAYING in one guarded update, so a status the user picks wins. Old finished games get their year from the ending achievement. GET /api/library/stats serves the groups; the public profile shows only the groups in the profile ticket.
14 KiB
To Be Played – Core Service
Business service of To Be Played, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog (status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
To Be Played is split into three repositories:
| Repository | Role |
|---|---|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
| ToBePlayed-Core (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
| ToBePlayed-Frontend | React single-page app |
Features
- Steam sync: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly sync. Resilience4j retries and rate-limits every Steam call; Caffeine caches game counts and player summaries.
- Backlog: status (
BACKLOG,PLAYING,FINISHED,COMPLETED= 100 %,DROPPED), rating 1–5, notes, priority, filters and sorting. "Pile of Shame" summary with a random suggestion of what to play. - Picker: up to 10 random backlog games for a time budget (IGDB normal time to beat) and a mood, HIGH priority
first, then MEDIUM, then the rest. Games already shown can be excluded for a reroll. Moods
STORY,COOP,CHILL,ACTION,SCARYcome from IGDB genres, themes and game modes;SPEEDRUNfrom speedrun.com. - Speedrun games: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns
the speedrun.com link as
speedrunUrl. speedrun.com has no Steam or IGDB link and no key, so one queue works for all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days. - Achievements: after every library sync a background job fetches the achievement schema of each owned game and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5 games closest to 100 %.
- Playtime history: one row per game and day, for the activity view and the "most played in the last two weeks" dashboard banner.
- Finished from achievements or playtime:
EndingAchievementfinds the one achievement of a game that means "story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than 3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered "Not yet", the achievement sync sets FINISHED, but only for a certain ending: its text says so and no other achievement of the game could be the end. A status the user chose is never changed; such games get a hint on the game page instead. A second, weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint still lets the ending hint through. - Hide games: a user can hide a Steam game (e.g. a playtest or a tool). It leaves every list and number: library,
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
lists hidden games with
hidden=true. - Stats:
UserStatsServicebuilds the numbers of the stats page per group, so every page asks only for the groups it shows: totals and statuses; the pile (time to beat, weeks "at your pace" of the last 90 days, years of 40-hour weeks, oldest unplayed game); most played and most loyal game (most months with playtime); top IGDB genres and moods; moon trips (Apollo 11, 195.3 h), longest day, share under one hour; finished games per year (from the status history); the Pile of Shame trend (90 days ofbacklog_snapshot, plus "this month: added / done", where the first sync of a Steam account is no "added", seeuser_steam_sync.history_since). Every number counts only listed games. - Status history: a database trigger logs every status change of a library game in
user_game_status_change(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older changes are not known. - Wishlist: synced from Steam with store price and discount.
- Broken image repair: the frontend reports a Steam cover or achievement icon that does not load. At 01:30
(Europe/Berlin) a job checks up to 200 reports. A cover that loads again is left alone; else the store's hashed
header picture (
IStoreBrowseService/GetItemswith assets) goes tosteam_game.image_url. When a few achievement icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times, then after 30 days. - Time to beat: story, extras and completionist hours from the IGDB API. Optional: without credentials the feature is off and the rest works.
- Public profile data: totals, status counts, games in progress and finished games with rating, perfect games
and average completion. Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID.
statsholds only the stat groups the user shares (the ticket'sprofileStatsclaim, set in Auth); none by default.
Authentication
Core never issues tokens. It verifies Auth's EdDSA (Ed25519) JWTs with public keys only. It loads them from Auth's
JWKS by the token's kid and loads again when a new kid shows up (at most every 10 seconds). Loaded keys stay
until Core restarts. JWT_PUBLIC_KEY is used only until the first JWKS load works. The token comes from Auth's
tbp_access cookie or a Bearer header, whichever verifies first. Core reads userId and steamId from the claims.
POST, PUT and DELETE need an X-TBP-Client or Authorization header (CSRF guard), and CORS allows only the
frontend origin.
A token with a typ claim (for example a profile ticket) is rejected on normal endpoints; the profile ticket is
accepted only by GET /api/public/profiles/library in the X-Profile-Ticket header.
API
| Method | Path | Auth |
|---|---|---|
GET |
/api/library/games, /api/library/games/{appid} |
JWT |
PUT |
/api/library/games/{appid}/backlog |
JWT |
PUT |
/api/library/games/{appid}/status (status only, keeps rating, notes, priority) |
JWT |
PUT |
/api/library/games/{appid}/finished-hint ({dismissed: ENDING|PLAYTIME}, "Not yet"; 204) |
JWT |
PUT |
/api/library/games/{appid}/hidden ({hidden}; 409 MANUAL_GAME for a game added by hand) |
JWT |
POST |
/api/library/sync |
JWT |
POST |
/api/library/broken-images ({appid, kind}, kind COVER or ACHIEVEMENT_ICONS; 204) |
JWT |
GET |
/api/library/picker?maxMinutes=&mood=&count=&exclude= |
JWT |
GET |
/api/library/stats?groups= (OVERVIEW, PILE, TOP_GAMES, TASTE, HABITS, FINISHED, TREND; none = all) |
JWT |
GET |
/api/library/achievements/summary |
JWT |
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 |
Tech stack
- Java 25, Spring Boot 4.1 (Web MVC, Security, Data JPA, Validation, Cache with Caffeine), virtual threads
- PostgreSQL 17, Flyway migrations
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
- JJWT with Ed25519 public key
- Spring Boot Actuator + Micrometer (Prometheus)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image
Architecture
A modular monolith. Each module under core_service.modules is a vertical slice that could move to its own
service later.
flowchart LR
Browser["Frontend in the browser"]
subgraph CoreService["ToBePlayed-Core"]
Security["configuration.security<br/>JwtAuthenticationFilter"]
subgraph Library["library"]
LibApi["api"] --> LibService["service"] --> LibRepo["repo"]
end
Stats["stats"]
Steam["steam_adapter"]
Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"]
LibService --> Steam
LibService --> Igdb
LibService --> Speedrun
end
Browser -->|"tbp_access cookie"| Security
Security -->|"JWKS by kid"| Auth["ToBePlayed-Auth"]
Steam --> SteamApi["Steam Web API"]
Steam -->|"HEAD"| SteamCdn["Steam CDN"]
Igdb --> IgdbApi["IGDB API"]
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
Stats --> Db
| Module | Content |
|---|---|
library |
Sync, backlog, manual games, playtime history, wishlist, achievements, public profile data |
stats |
Public numbers for the landing page |
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 |
Inside a module: api (controllers, DTOs), service (business logic), domain (JPA entities) and repo
(repositories). src/test/java/core_service/architecture/ArchitectureTest.java checks these rules on every build:
- Modules do not depend on each other. Only
librarymay usesteam_adapter,igdb_adapterandspeedrun_adapter. - No cycles between
modules,sharedandconfiguration. apinever usesrepo, andreponever usesapi.- Every class in
repoends withRepository. sharednever usesmodules, andmodulesnever useconfiguration.- A
*Serviceclass in a module'sservicepackage has@Service. A*Controllerclass inapihas@RestController,@ControllerAdviceor@RestControllerAdvice.
Sync flow
GET /api/library/games,/games-countand/pickercallLibrarySyncService.syncIfStale. It starts a sync when the last one is older than 24 hours, older than 15 minutes after a failure, or was for another Steam ID.POST /api/library/syncstarts one at once, but at most every 60 seconds (else429withRetry-After).- A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A
request without a
steamIdclaim (the user unlinked the Steam account in Auth) takes the user out of it. Afterwards it writes every user's pile size of the day tobacklog_snapshot, also for users without Steam, and dates finished games by the unlock of their ending achievement (FinishedEstimateService,user_game.finished_at_estimate). LibrarySyncWorkerruns in the background.user_steam_synclets only one run per user start.- The worker loads the wishlist and the owned games from Steam. It updates
steam_gameanduser_game, adds the playtime growth of the day touser_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 games, the library stays as it is. TimeToBeatServiceandMoodServiceask 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.- After the commit,
LibrarySyncedEventstartsAchievementSyncServiceandSpeedrunService(backlog games only) in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
The frontend polls GET /api/library/sync-status until the run ends.
At 01:30 ImageRepairService repairs the pictures reported through POST /api/library/broken-images (table
broken_image, one row per game and kind). Reports only count for Steam games the user owns.
Manual games next to Steam games
A game the user adds by hand comes from IGDB (ManualGameService). Steam games and manual games share
steam_game and user_game and one key, the column appid (see GameKeys):
appid > 0: the Steam appid. A manual game gets it when IGDB knows the game on Steam.appid < 0: minus the IGDB id, for a game that is not on Steam. Steam appids are always positive, so the two never meet.
user_game.source (GameSource) says who owns the row. The sync never changes or removes a MANUAL row, with
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
STEAM, and status, rating and notes stay. Only MANUAL rows can be edited (PUT /api/library/games/{appid}/manual)
or deleted (DELETE /api/library/games/{appid}). Time to beat uses the Steam appid for positive keys and the IGDB
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
a second row when IGDB learns its Steam appid later and the user owns it on Steam.
Run locally
Needs Java 25 and Docker.
./mvnw spring-boot:run -Dspring-boot.run.profiles=dev
Spring Boot's Docker Compose support starts PostgreSQL from compose.yaml, Flyway applies the migrations, and the
service listens on http://localhost:8090. Health: /actuator/health, metrics: /actuator/prometheus.
In the dev profile a request without an Authorization header gets a dummy user, so the API is usable without a
running Auth service.
Configuration comes from environment variables:
| Variable | Purpose |
|---|---|
JWT_JWKS_URL |
Auth's /.well-known/jwks.json |
JWT_PUBLIC_KEY |
Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, JWT_JWKS_URL, or both |
STEAM_API_KEY |
Steam Web API key |
IGDB_CLIENT_ID, IGDB_CLIENT_SECRET |
Twitch app for IGDB (optional) |
DB_PASSWORD |
Local PostgreSQL password |
Tests
./mvnw clean verify
Runs unit, WebMvc, persistence and integration tests plus the ArchUnit rules. Needs Docker for Testcontainers.
Roadmap
- Sale alerts for wishlist games.
- Move
steam_adapterDTOs out ofapi, then turn on strict module independence in ArchUnit.
License
All rights reserved. The source is public for reading only.