Compare commits

...
49 Commits
Author SHA1 Message Date
lobo e11fae8e31 feat(follow): add player search with rate limit (#57)
Build, Test & Push Core Service / quality-check (push) Successful in 2m19s
Build, Test & Push Core Service / docker (push) Successful in 1m34s
Find public profiles by name: GET /api/follows/players?q=.
Queries under 2 characters find nothing; more than 30 searches
per minute get 429. LIKE escaping is shared with the library search.
2026-10-21 14:54:48 +02:00
lobo c42526bc16 feat(library): sort by name, last played and time to beat (#56)
Sort by name ignores upper and lower case.
Games never played, or without a time to beat, come last.
2026-10-21 11:09:44 +02:00
lobo 4fbf572f32 perf(datasource): reuse query plans on core connections (#49)
Build, Test & Push Core Service / quality-check (push) Successful in 2m11s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
Planning was 94% of Dashboard DB time, and prod never reused a plan.
Force generic plans and prepare on first use. Burst: 443 -> 229 ms.
2026-10-20 11:41:31 +02:00
lobo 6f2d7b4885 fix(library): book psn play on its last played day (#53)
Build, Test & Push Core Service / quality-check (push) Successful in 2m35s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
A newly linked PSN title booked its whole history on the sync day.
Now only new play counts, on its last played day. A day is capped
at 24 h. V49 deletes the wrong old day rows.
2026-10-20 09:06:53 +02:00
lobo d3c9c7558c feat(follow): add feed link data and game endpoints (#52, #51)
Build, Test & Push Core Service / quality-check (push) Successful in 2m34s
Build, Test & Push Core Service / docker (push) Successful in 1m52s
Feed notes get profileName and appid; follows get lastActivityAt.
New GET /api/library/games/{appid}/preview: catalog data, any game.
New GET /api/public/profiles/library/games/{appid}: one profile game.
2026-10-14 16:23:11 +02:00
lobo ec66bd5ead chore: remove unused dev compose and prometheus dependency
Build, Test & Push Core Service / quality-check (push) Successful in 3m9s
Build, Test & Push Core Service / docker (push) Successful in 2m4s
Prometheus is not in use. Only /actuator/health is exposed.
2026-10-14 15:22:20 +02:00
lobo 54946ef0eb docs(readme): sync features, endpoints and config with code
Build, Test & Push Core Service / quality-check (push) Successful in 2m38s
Build, Test & Push Core Service / docker (push) Successful in 2m3s
The readme had drifted from the code. Update sync rules, thread pools,
API tables, env vars and the CI setup.
2026-10-14 14:51:40 +02:00
lobo dba4fedfa1 test(wiremock): add 30-game library and 6 achievement sets
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Build, Test & Push Core Service / docker (push) Successful in 1m32s
Local WireMock library grows from 8 to 30 games.
Six games get schema, achievement and percentage stubs.
LocalMocksParseTest checks each stub appid is an owned game.
2026-10-13 12:03:56 +02:00
lobo cdfbb4a863 fix(follow): keep sync status rows for the sync digest
A hand-only digest used to move the cursor past all status rows.
It now stops at the last by_user change, so the sync digest still
gets its status detail lines.
2026-10-13 11:15:10 +02:00
lobo c6bfea1976 feat(inbox): add details for digest notes
Digest notes now keep the lines behind their counts in
inbox_note_detail (V48). GET /api/inbox/{id}/details shows them
while the reader still follows the subject and it is public.
2026-10-13 09:17:41 +02:00
lobo 03469f9eb5 perf(library): add dashboard-games and stored game detail
Build, Test & Push Core Service / quality-check (push) Successful in 2m26s
Build, Test & Push Core Service / docker (push) Successful in 1m32s
Add GET /api/library/dashboard-games for the top and playing tiles
in one call. It has no count query.
GET /api/library/games/{appid}?refresh=false answers from stored
data and sets achievementsStale. It does not wait for Steam.
entryPlatforms reads the view once in a materialized CTE:
planning ~33 ms -> 15 ms.
Test CORS Access-Control-Max-Age 3600 on preflight.

Refs #47
2026-10-12 09:25:49 +02:00
lobo b1aaec0615 fix(speedrun): pause queue on 429/420 instead of dropping it
speedrun.com returns 429 or 420 when we ask too fast. These were
counted as errors: 3 in a row dropped both queues. Now the thread
pauses and retries the same game. Rate: 1 request per 2 s. Refs #48
2026-10-12 08:25:43 +02:00
lobo 9efed0ab1d feat(speedrun): match speedrun.com games only when sure
Build, Test & Push Core Service / quality-check (push) Successful in 2m32s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
Loose name matches gave wrong links. Now a link needs one sure hit:
same name and within 1 year of IGDB, or one hit when no year is known.
V47 marks old matches (not corrected ones) for one re-check. Refs #8
2026-10-11 17:23:32 +02:00
lobo bb195a1f79 test(library): compare trend move times as Instants
Sort the parsed Instant values, not the raw strings. String order
is wrong when fraction lengths differ. Refs #45.
2026-10-11 16:46:03 +02:00
lobo 0f56f5bb0f feat(library): add ALL_UNLOCKED sync change for last achievement
Build, Test & Push Core Service / quality-check (push) Successful in 2m30s
Build, Test & Push Core Service / docker (push) Successful in 1m35s
A sync that unlocks the last achievement of a game not COMPLETED
adds an ALL_UNLOCKED change. The overview hides it once the game is
COMPLETED, hidden or removed. Refs #21
2026-10-11 16:22:49 +02:00
lobo 1de8acef2e fix(library): merge manual game into its Steam row on sync
Steam sync added a second row for a game the user added by hand (#9).
The manual row moves to the Steam appid and keeps its data, such as
status, notes, sets and PSN links. Manual rows on other platforms stay
and join the Steam game's set.
2026-10-11 14:55:09 +02:00
lobo 59d6b3394d fix(rewards): check last achievement unlock for manual COMPLETED
Refs #44. Check the last achievement unlock, not the first status change.
Old 100% games no longer pass. New reason COMPLETION_TOO_OLD.
Add earlySince to the games and wallet GET responses.
2026-10-11 13:51:34 +02:00
lobo 1a9a4e912c fix(rewards): let manual finish open COMPLETED for Steam edition
A paid FINISHED_MANUAL on a set edition blocked COMPLETED for the
Steam game forever. It now opens COMPLETED, but only when the Steam
game's first finish is on or after app.rewards.early-since. Refs #35
2026-10-11 13:07:26 +02:00
lobo 6215bb0dec fix(library): count trend never played by set total
Nightly snapshot and live trend point use the set total, as stats do.
Newest point now equals stats "never started". Old rows keep old value.
Refs #43
2026-10-11 12:11:47 +02:00
lobo 8f884040d8 fix(stats): count each set once by its set playtime
Played, never started, under one hour and oldest unplayed used the
counted edition's own minutes, so a set played only on PS5 showed as
never started. They and their drill-down lists now use the set total
(user_game_entry_playtime). Refs #38. Nightly snapshot (#43) unchanged.
2026-10-11 11:52:45 +02:00
lobo 0550f3fe9c feat(configuration): add request timing log
Build, Test & Push Core Service / quality-check (push) Successful in 2m28s
Build, Test & Push Core Service / docker (push) Successful in 1m34s
One INFO line per API request: method, handler pattern, status, ms.
No ids or query strings in the line. Refs #40.
2026-10-11 11:05:18 +02:00
lobo e18b799501 perf(library): bind owned-games subqueries to user
Subqueries in OWNED_WHERE had no userId filter, so Postgres hashed
rows of all users. Skip the source filter when all sources are set.
Refs #40.
2026-10-11 11:05:18 +02:00
lobo 4d49b0f023 feat(library): soft delete removed games and add restore
Build, Test & Push Core Service / quality-check (push) Successful in 1m46s
Build, Test & Push Core Service / docker (push) Successful in 1m26s
Removed games could not come back (#39). Remove now sets removed_at
and restore clears it. V46 brings back PSN games removed before.
2026-10-11 09:52:11 +02:00
lobo 696e06e7fe fix(test): delete user_game_playtime_day rows in seed
Build, Test & Push Core Service / quality-check (push) Successful in 1m45s
Build, Test & Push Core Service / docker (push) Successful in 1m38s
RewardControllerTest leaves user_game_playtime_day rows for user 1,
affecting paceWeeks calculation. Delete them in seed() to ensure
clean test isolation.
2026-10-09 19:32:35 +02:00
lobo b24fd91e88 feat(rewards): pay early finish points and top-up on proof
Build, Test & Push Core Service / quality-check (push) Failing after 1m49s
Build, Test & Push Core Service / docker (push) Skipped
A linked PlayStation title no longer blocks. Its playtime never
proves a finish. An unproven Steam finish pays 10 early points,
the rest comes with proof. Back pay only from app.rewards.early-since.
2026-10-09 16:00:06 +02:00
lobo 7c4b475321 feat(rewards): add game points endpoint with reason codes
Frontend shows why a game earned no points after a status save.
Settle runs first. Rules and amounts are not changed.
2026-10-09 15:25:04 +02:00
lobo 4ed8a341af feat(library): use set total playtime in sort and stats
Sort, total playtime, most played, genres, moods and the public
profile use the set total of an entry, as the library card does.
New view user_game_entry_playtime (V45) is the one SQL rule.
2026-10-09 14:35:07 +02:00
lobo 1ff1e061c7 feat(library): show play per platform on game detail
Store PSN play per title. A PS title on a Steam row keeps its minutes.
Steam rtime_last_played now fills last played.
Dropped? questions read the newest PSN last played too.
2026-10-09 13:55:57 +02:00
lobo 413381d404 feat(library): show one entry per game in every list
Gitea #34 slice 2 of 3. Lists and stats count a set once.
V43 views map an entry to its editions and platforms.
Remove the hideDuplicates param; GameDto gets platforms[].
2026-10-09 11:31:11 +02:00
lobo 82d3b59165 feat(library): group same game across platforms into sets
One entry per game across platforms (Gitea #34, slice 1 of 3).
Same IGDB game is linked; a user can split one key off its set.
V42 keeps status, rating and notes per set; rewards pay once per set.
2026-10-09 10:57:23 +02:00
lobo 8e4c382563 feat(library): add source filter to owned games
A row matches when its own source, or the source of a listed
linked edition, is selected. The hidden view ignores the filter.
2026-10-09 10:03:05 +02:00
lobo 6cd92f3e83 feat(library): add question queue for open game decisions
Add open questions per game: Start it?, Finished?, Dropped?, Rate it?.
A "No" is saved in user_game_question (V41) and snoozes the question.
Account cleanup clears the new table too. Refs Gitea #32.
2026-10-09 09:31:48 +02:00
lobo 0ddb345a41 feat(psn): sync PlayStation trophies as achievements
Build, Test & Push Core Service / quality-check (push) Successful in 1m30s
Build, Test & Push Core Service / docker (push) Successful in 1m49s
Sets map by exact title id, else by name and platform.
Trophy gains count as achievements; platinum or all earned is perfect.
Max 30 PSN calls per run; trophy lists load only when changed.
Hidden trophies stay hidden (name and detail) until earned.
2026-10-08 09:18:15 +02:00
lobo f5ca7237a9 feat(psn): sync PlayStation titles in the library sync
Exact IGDB matches are added; unsure titles wait in a review import.
Playtime and last played come from PSN: summed per game, not editable.
PSN games give no Pile Points. Users with only a PSN link sync too
(V39 drops steam_id NOT NULL). Linking starts a sync, or schedules one
for the end of the cooldown. Inbox notes 2 days before the PSN link
ends and when it has ended.
2026-10-08 08:15:37 +02:00
lobo 7ac887ed5a feat(psn): link PlayStation account and import played games
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m27s
The NPSSO is used once to get a refresh token; only that token is
stored, sealed with AES-GCM (SecretBox, key APP_PSN_TOKEN_KEY; no key
= feature off). One-time import of played PS4/PS5 titles through the
import pipeline as source PSN. Unlink keeps the games. One PSN call
per user at a time. IGDB search now sends UTF-8, so non-ASCII names
like "Yōtei" match again.
2026-10-07 13:30:48 +02:00
lobo e87f40bba0 feat(picker): add FRIENDS row from followed users
Backlog games a followed user finished or played >= 5 h. One game per
friend; strongest signal first (finished, then playtime). Requires an
active follow of a public profile that shows SUMMARY or STATUS. Skips
DROPPED games, blocked and deleted users, hidden and removed games.
2026-10-07 09:42:10 +02:00
lobo 117ff61730 feat(picker): add series and studio rows from IGDB credits
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m36s
New V37 tables game_credit and game_credit_check store IGDB
collections and developer/publisher companies per game.
A scheduled job backfills existing games (250 per 30 s);
sync, import, manual add, merge and detail page refresh them.
SERIES row: next part of a collection the user played.
STUDIO row: backlog games from developers of top played games.
2026-10-06 20:22:53 +02:00
lobo 8dd398321c feat(picker): return rows of up to 3 games per source
Picker returns categories in fixed order: NEXT_UP, SIMILAR, LONGEST,
RANDOM, each with up to 3 games. Empty rows are left out.
Param `count` is removed; new optional `category` loads one row (reroll).
Budget and exclude apply to all rows; mood only to RANDOM.
SIMILAR takes the 3 most played games, one similar game each.
Remove PickMix.
2026-10-06 19:41:24 +02:00
lobo 867dc4c4d1 feat(follow): add user follows with activity inbox digest
Build, Test & Push Core Service / quality-check (push) Successful in 1m26s
Build, Test & Push Core Service / docker (push) Successful in 1m31s
2026-10-06 16:22:22 +02:00
lobo 2dba863c54 feat(stats): add owners endpoint to list users who own a game
Build, Test & Push Core Service / quality-check (push) Successful in 1m21s
Build, Test & Push Core Service / docker (push) Successful in 1m33s
2026-10-06 13:19:58 +02:00
lobo 7747bc7b8b feat(library): add hand-ordered backlog with next-up picker source
Build, Test & Push Core Service / quality-check (push) Successful in 1m25s
Build, Test & Push Core Service / docker (push) Successful in 1m29s
2026-10-06 09:06:51 +02:00
lobo 7b538b7f30 feat(library): exclude unreleased games from picker and pile
Build, Test & Push Core Service / quality-check (push) Successful in 1m22s
Build, Test & Push Core Service / docker (push) Successful in 1m34s
2026-10-05 20:33:25 +02:00
lobo 33b230ee91 feat(picker): mix similar, longest waiting and random picks
Build, Test & Push Core Service / quality-check (push) Successful in 2m37s
Build, Test & Push Core Service / docker (push) Successful in 1m45s
Picker slots rotate three sources: backlog games that share moods with
one of the top 5 played games, oldest first seen, and random. Each game
carries a reason (source, anchor game, first seen). Empty slots fall
back to random. Issue #28.
2026-10-05 16:13:19 +02:00
lobo 10b7dc2272 feat(account): reject tokens of blocked users and old roles
Build, Test & Push Core Service / quality-check (push) Successful in 1m23s
Build, Test & Push Core Service / docker (push) Successful in 1m30s
V32 stores the last role change per user, sent by Auth. A token
issued before that change, or of a blocked user, gets 401 within
about 30 s instead of working until it expires.
2026-10-04 20:09:22 +02:00
lobo 35b1db45e6 feat(sync): start Steam sync on visit when data is older than 20 h
A visit starts a background sync when the last successful sync is
older than 20 h; one try per hour, one sync at a time. Migration V31
adds last_success_at and login_sync_tried_at. The nightly sync skips
only users with a successful sync today after 23:00. games-count
returns syncStarted.
2026-10-04 19:05:23 +02:00
lobo a8e208321f feat(sync): record what each Steam sync changed
Build, Test & Push Core Service / quality-check (push) Successful in 1m17s
Build, Test & Push Core Service / docker (push) Successful in 1m28s
Allows dashboard to show what the last sync changed. Migration V30
creates sync_change and sync_result tables. Sync records additions,
removals, playtime, achievements, and status changes per game.
2026-10-04 11:36:09 +02:00
lobo 065048415c feat(library): add drill-down lists for stat numbers
Build, Test & Push Core Service / quality-check (push) Successful in 1m17s
Build, Test & Push Core Service / docker (push) Successful in 1m27s
The number and list share the same SQL query, so they cannot drift.
StatKey enum maps each statistic to its query, validation rules, and visibility.
StatGamesService reads games per key with 40-game pages; repositories share the
same set logic as the stat counts. Three endpoints open games: own library stats,
public profile stats (with ticket checks), and public landing-page stats (cached
10 min). Rewards track and bonus rows support drill-down too.
2026-10-03 18:51:59 +02:00
lobo 74cc8297ce feat(rewards): add 14 new shop items with twin backgrounds
Build, Test & Push Core Service / quality-check (push) Successful in 1m14s
Build, Test & Push Core Service / docker (push) Successful in 1m23s
Each page background gets a matching banner background. Tests verify
pairing, price constraints, and code length limits
2026-10-03 17:31:19 +02:00
lobo 8eab98df74 feat(rewards): unlock items by progress and skins
Build, Test & Push Core Service / quality-check (push) Successful in 1m11s
Build, Test & Push Core Service / docker (push) Successful in 1m27s
Five progress tracks: games finished, completed, weekly finish
streaks, pile-down months and play-more months. Settle books UNLOCK
rows once, also retroactively, when a track reaches an item's target.
12 unlock-only items (incl. the Steam skin) and 3 skins to buy. The
wallet returns progress; buying an unlock item answers NOT_FOR_SALE.
2026-10-02 15:40:46 +02:00
428 changed files with 30287 additions and 966 deletions

No files matched your search

-22
View File
@@ -1,22 +0,0 @@
# docker-compose.dev.yml
services:
# ─────────────────────────────────────────────
# LOCAL DATABASE FOR DEVELOPMENT
# ─────────────────────────────────────────────
tbp-db-local:
image: postgres:17-alpine
container_name: tbp_db_local
restart: unless-stopped
ports:
# Map the container port 5432 to your local machine port 5432
- "5432:5432"
environment:
- POSTGRES_DB=tbp_core
- POSTGRES_USER=tbp_user
- POSTGRES_PASSWORD=${DB_PASSWORD}
volumes:
# Use a local volume to persist data across container restarts
- tbp_local_db_data:/var/lib/postgresql/data
volumes:
tbp_local_db_data:
@@ -0,0 +1,17 @@
[
{
"id": 900003,
"name": "Mega Man Legacy Collection",
"first_release_date": 1451520000,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 4",
"abbreviation": "PS4"
}
],
"total_rating_count": 300
}
]
@@ -0,0 +1,21 @@
[
{
"id": 900002,
"name": "Ghost of Tsushima",
"first_release_date": 1595548800,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 4",
"abbreviation": "PS4"
},
{
"name": "PlayStation 5",
"abbreviation": "PS5"
}
],
"total_rating_count": 2000
}
]
@@ -0,0 +1,17 @@
[
{
"id": 900001,
"name": "Ghost of Yōtei",
"first_release_date": 1760054400,
"cover": {
"image_id": "co1r7f"
},
"platforms": [
{
"name": "PlayStation 5",
"abbreviation": "PS5"
}
],
"total_rating_count": 800
}
]
+81
View File
@@ -0,0 +1,81 @@
{
"titles": [
{
"titleId": "PPSA26344_00",
"name": "Ghost of Yōtei",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 31,
"playDuration": "PT65H9M15S",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-yotei.png",
"firstPlayedDateTime": "2025-10-03T18:20:11.000Z",
"lastPlayedDateTime": "2026-10-01T20:41:07.000Z",
"concept": {
"id": 10003923,
"titleIds": ["PPSA26344_00"]
}
},
{
"titleId": "CUSA02521_00",
"name": "Mega Man Legacy Collection™",
"category": "ps4_game",
"service": "none(purchased)",
"playCount": 9,
"playDuration": "PT8H30M28S",
"imageUrl": "https://image.api.playstation.com/local/mega-man-legacy.png",
"firstPlayedDateTime": "2017-01-14T09:02:51.000Z",
"lastPlayedDateTime": "2024-03-02T16:10:30.000Z",
"concept": {
"id": 202697,
"titleIds": ["CUSA02521_00", "CUSA02522_00"]
}
},
{
"titleId": "PPSA03208_00",
"name": "Ghost of Tsushima DIRECTOR'S CUT (PS5)",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 22,
"playDuration": "PT64H3S",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
"firstPlayedDateTime": "2021-08-20T19:44:02.000Z",
"lastPlayedDateTime": "2023-12-11T21:15:48.000Z",
"concept": {
"id": 235227,
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
}
},
{
"titleId": "CUSA17722_00",
"name": "Ghost of Tsushima",
"category": "ps4_game",
"service": "none(purchased)",
"playCount": 3,
"playDuration": "PT1H30M",
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
"firstPlayedDateTime": "2020-07-17T10:00:00.000Z",
"lastPlayedDateTime": "2020-07-19T12:00:00.000Z",
"concept": {
"id": 235227,
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
}
},
{
"titleId": "PPSA00000_00",
"name": "Monster Hunter Wilds",
"category": "ps5_native_game",
"service": "none(purchased)",
"playCount": 5,
"playDuration": "PT12H",
"imageUrl": "https://image.api.playstation.com/local/monster-hunter-wilds.png",
"firstPlayedDateTime": "2025-03-01T10:00:00.000Z",
"lastPlayedDateTime": "2025-03-09T22:00:00.000Z",
"concept": {
"id": 10000001,
"titleIds": ["PPSA00000_00"]
}
}
],
"totalItemCount": 5,
"nextOffset": null
}
+9
View File
@@ -0,0 +1,9 @@
{
"access_token": "local-access",
"token_type": "bearer",
"expires_in": 3599,
"scope": "psn:mobile.v2.core psn:clientapp",
"id_token": "local-id",
"refresh_token": "local-refresh",
"refresh_token_expires_in": 863999
}
@@ -0,0 +1,60 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": true,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "platinum",
"trophyName": "Legend of Yōtei",
"trophyDetail": "Earn all other trophies.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
"trophyGroupId": "default"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "gold",
"trophyName": "Wolf of Ezo",
"trophyDetail": "Finish the story.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
"trophyGroupId": "default"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"trophyName": "Lone Blade",
"trophyDetail": "Win a duel.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
"trophyGroupId": "default"
},
{
"trophyId": 3,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "First Steps",
"trophyDetail": "Leave the village.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t3.png",
"trophyGroupId": "default"
},
{
"trophyId": 4,
"trophyHidden": true,
"trophyType": "bronze",
"trophyName": "Secret Name",
"trophyDetail": "Secret detail text.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t4.png",
"trophyGroupId": "default"
},
{
"trophyId": 5,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Island Trial",
"trophyDetail": "Finish the DLC trial.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t5.png",
"trophyGroupId": "001"
}
]
}
@@ -0,0 +1,33 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": false,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Blue Bomber",
"trophyDetail": "Start the game.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
"trophyGroupId": "default"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "bronze",
"trophyName": "Rock On",
"trophyDetail": "Beat Mega Man 1.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
"trophyGroupId": "default"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"trophyName": "Robot Master",
"trophyDetail": "Beat Mega Man 2.",
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
"trophyGroupId": "default"
}
]
}
@@ -0,0 +1,58 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": true,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "platinum",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "2.1",
"earnedDateTime": "2026-09-20T18:00:00Z"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "gold",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "30.5",
"earnedDateTime": "2026-09-21T18:00:00Z"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "55.0",
"earnedDateTime": "2026-09-22T18:00:00Z"
},
{
"trophyId": 3,
"trophyHidden": false,
"trophyType": "bronze",
"earned": true,
"trophyRare": 3,
"trophyEarnedRate": "90.2",
"earnedDateTime": "2026-09-23T18:00:00Z"
},
{
"trophyId": 4,
"trophyHidden": true,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "1.3"
},
{
"trophyId": 5,
"trophyHidden": false,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "12.0"
}
]
}
@@ -0,0 +1,31 @@
{
"trophySetVersion": "01.00",
"hasTrophyGroups": false,
"trophies": [
{
"trophyId": 0,
"trophyHidden": false,
"trophyType": "bronze",
"earned": false,
"trophyRare": 3,
"trophyEarnedRate": "40.0"
},
{
"trophyId": 1,
"trophyHidden": false,
"trophyType": "bronze",
"earned": true,
"earnedDateTime": "2024-03-02T16:10:30Z",
"trophyRare": 3,
"trophyEarnedRate": "25.5"
},
{
"trophyId": 2,
"trophyHidden": false,
"trophyType": "silver",
"earned": false,
"trophyRare": 2,
"trophyEarnedRate": "10.0"
}
]
}
@@ -0,0 +1,33 @@
{
"titles": [
{
"npTitleId": "PPSA26344_00",
"trophyTitles": [
{
"npServiceName": "trophy2",
"npCommunicationId": "NPWR90001_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Ghost of Yōtei",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
"trophyTitlePlatform": "PS5",
"hasTrophyGroups": true,
"definedTrophies": {
"bronze": 3,
"silver": 1,
"gold": 1,
"platinum": 1
},
"progress": 66,
"earnedTrophies": {
"bronze": 1,
"silver": 1,
"gold": 1,
"platinum": 1
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
}
]
}
]
}
@@ -0,0 +1,102 @@
{
"totalItemCount": 4,
"nextOffset": null,
"trophyTitles": [
{
"npServiceName": "trophy2",
"npCommunicationId": "NPWR90001_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Ghost of Yōtei",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
"trophyTitlePlatform": "PS5",
"hasTrophyGroups": true,
"definedTrophies": {
"bronze": 3,
"silver": 1,
"gold": 1,
"platinum": 1
},
"progress": 66,
"earnedTrophies": {
"bronze": 1,
"silver": 1,
"gold": 1,
"platinum": 1
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90002_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Mega Man Legacy Collection",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/megaman-set.png",
"trophyTitlePlatform": "PS4",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 2,
"silver": 1,
"gold": 0,
"platinum": 0
},
"progress": 33,
"earnedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2024-03-02T16:10:30Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90003_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Old PS3 Game",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ps3-set.png",
"trophyTitlePlatform": "PS3",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 5,
"silver": 0,
"gold": 0,
"platinum": 0
},
"progress": 20,
"earnedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": false,
"lastUpdatedDateTime": "2012-05-01T10:00:00Z"
},
{
"npServiceName": "trophy",
"npCommunicationId": "NPWR90004_00",
"trophySetVersion": "01.00",
"trophyTitleName": "Hidden By The User",
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/hidden-set.png",
"trophyTitlePlatform": "PS4",
"hasTrophyGroups": false,
"definedTrophies": {
"bronze": 1,
"silver": 0,
"gold": 0,
"platinum": 0
},
"progress": 0,
"earnedTrophies": {
"bronze": 0,
"silver": 0,
"gold": 0,
"platinum": 0
},
"hiddenFlag": true,
"lastUpdatedDateTime": "2020-01-01T10:00:00Z"
}
]
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_B1",
"percent": 81.0
},
{
"name": "ACH_B2",
"percent": 57.3
},
{
"name": "ACH_B3",
"percent": 39.8
},
{
"name": "ACH_B4",
"percent": 26.5
},
{
"name": "ACH_B5",
"percent": 23.6
},
{
"name": "ACH_B6",
"percent": 18.2
}
]
}
}
@@ -0,0 +1,38 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_A1",
"percent": 88.2
},
{
"name": "ACH_A2",
"percent": 52.7
},
{
"name": "ACH_A3",
"percent": 74.1
},
{
"name": "ACH_A4",
"percent": 31.4
},
{
"name": "ACH_A5",
"percent": 44.9
},
{
"name": "ACH_A6",
"percent": 36.0
},
{
"name": "ACH_A7",
"percent": 28.3
},
{
"name": "ACH_A8",
"percent": 4.6
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_F1",
"percent": 91.2
},
{
"name": "ACH_F2",
"percent": 64.8
},
{
"name": "ACH_F3",
"percent": 48.5
},
{
"name": "ACH_F4",
"percent": 37.1
},
{
"name": "ACH_F5",
"percent": 22.4
},
{
"name": "ACH_F6",
"percent": 11.0
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_E1",
"percent": 83.4
},
{
"name": "ACH_E2",
"percent": 62.0
},
{
"name": "ACH_E3",
"percent": 44.3
},
{
"name": "ACH_E4",
"percent": 31.9
},
{
"name": "ACH_E5",
"percent": 27.5
},
{
"name": "ACH_E6",
"percent": 9.7
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_C1",
"percent": 78.5
},
{
"name": "ACH_C2",
"percent": 47.2
},
{
"name": "ACH_C3",
"percent": 33.6
},
{
"name": "ACH_C4",
"percent": 35.4
},
{
"name": "ACH_C5",
"percent": 41.0
},
{
"name": "ACH_C6",
"percent": 12.9
}
]
}
}
@@ -0,0 +1,30 @@
{
"achievementpercentages": {
"achievements": [
{
"name": "ACH_D1",
"percent": 70.3
},
{
"name": "ACH_D2",
"percent": 25.8
},
{
"name": "ACH_D3",
"percent": 30.2
},
{
"name": "ACH_D4",
"percent": 15.6
},
{
"name": "ACH_D5",
"percent": 12.1
},
{
"name": "ACH_D6",
"percent": 8.4
}
]
}
}
+205 -7
View File
@@ -1,6 +1,6 @@
{
"response": {
"game_count": 8,
"game_count": 30,
"games": [
{
"appid": 620,
@@ -8,7 +8,8 @@
"playtime_forever": 1450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1787300000
},
{
"appid": 1145360,
@@ -16,7 +17,8 @@
"playtime_forever": 3200,
"playtime_2weeks": 240,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1790500000
},
{
"appid": 413150,
@@ -24,7 +26,8 @@
"playtime_forever": 5400,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1788900000
},
{
"appid": 427520,
@@ -32,7 +35,8 @@
"playtime_forever": 12000,
"playtime_2weeks": 600,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1790900000
},
{
"appid": 504230,
@@ -40,7 +44,8 @@
"playtime_forever": 800,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1789200000
},
{
"appid": 367520,
@@ -56,7 +61,8 @@
"playtime_forever": 780,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1786900000
},
{
"appid": 892970,
@@ -64,6 +70,198 @@
"playtime_forever": 90,
"playtime_2weeks": 90,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 1245620,
"name": "ELDEN RING",
"playtime_forever": 6100,
"playtime_2weeks": 900,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 1091500,
"name": "Cyberpunk 2077",
"playtime_forever": 4300,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787900000
},
{
"appid": 292030,
"name": "The Witcher 3: Wild Hunt",
"playtime_forever": 5200,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789800000
},
{
"appid": 1086940,
"name": "Baldur's Gate 3",
"playtime_forever": 2800,
"playtime_2weeks": 300,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790150000
},
{
"appid": 730,
"name": "Counter-Strike 2",
"playtime_forever": 1900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788600000
},
{
"appid": 570,
"name": "Dota 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1174180,
"name": "Red Dead Redemption 2",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 105600,
"name": "Terraria",
"playtime_forever": 2600,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789300000
},
{
"appid": 294100,
"name": "RimWorld",
"playtime_forever": 3900,
"playtime_2weeks": 120,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790300000
},
{
"appid": 632470,
"name": "Disco Elysium - The Final Cut",
"playtime_forever": 1500,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1788100000
},
{
"appid": 548430,
"name": "Deep Rock Galactic",
"playtime_forever": 700,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790000000
},
{
"appid": 646570,
"name": "Slay the Spire",
"playtime_forever": 2200,
"playtime_2weeks": 60,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790450000
},
{
"appid": 2379780,
"name": "Balatro",
"playtime_forever": 1100,
"playtime_2weeks": 420,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790600000
},
{
"appid": 1794680,
"name": "Vampire Survivors",
"playtime_forever": 450,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790700000
},
{
"appid": 268910,
"name": "Cuphead",
"playtime_forever": 900,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1787600000
},
{
"appid": 814380,
"name": "Sekiro: Shadows Die Twice",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 374320,
"name": "DARK SOULS III",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1057090,
"name": "Ori and the Will of the Wisps",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 275850,
"name": "No Man's Sky",
"playtime_forever": 120,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1790200000
},
{
"appid": 526870,
"name": "Satisfactory",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
},
{
"appid": 1332010,
"name": "Stray",
"playtime_forever": 330,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true,
"rtime_last_played": 1789000000
},
{
"appid": 108600,
"name": "Project Zomboid",
"playtime_forever": 0,
"playtime_2weeks": null,
"img_icon_url": "",
"has_community_visible_stats": true
}
]
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cyberpunk 2077",
"success": true,
"achievements": [
{
"apiname": "ACH_B1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_B2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_B3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_B4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_B5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_B6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,49 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "ELDEN RING",
"success": true,
"achievements": [
{
"apiname": "ACH_A1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_A2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_A3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_A4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A6",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A7",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_A8",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Stray",
"success": true,
"achievements": [
{
"apiname": "ACH_F1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_F2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_F3",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F4",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F5",
"achieved": 0,
"unlocktime": 0
},
{
"apiname": "ACH_F6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Cuphead",
"success": true,
"achievements": [
{
"apiname": "ACH_E1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_E2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_E3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_E4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_E5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_E6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Disco Elysium - The Final Cut",
"success": true,
"achievements": [
{
"apiname": "ACH_C1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_C2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_C3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_C4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_C5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_C6",
"achieved": 0,
"unlocktime": 0
}
]
}
}
@@ -0,0 +1,39 @@
{
"playerstats": {
"steamID": "76561198000000001",
"gameName": "Slay the Spire",
"success": true,
"achievements": [
{
"apiname": "ACH_D1",
"achieved": 1,
"unlocktime": 1786000000
},
{
"apiname": "ACH_D2",
"achieved": 1,
"unlocktime": 1786259200
},
{
"apiname": "ACH_D3",
"achieved": 1,
"unlocktime": 1786518400
},
{
"apiname": "ACH_D4",
"achieved": 1,
"unlocktime": 1786777600
},
{
"apiname": "ACH_D5",
"achieved": 1,
"unlocktime": 1787036800
},
{
"apiname": "ACH_D6",
"achieved": 1,
"unlocktime": 1787296000
}
]
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cyberpunk 2077",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_B1",
"displayName": "Street Kid",
"description": "Get through the opening job.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B2",
"displayName": "Chrome Fan",
"description": "Install five implants.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B3",
"displayName": "Fast Driver",
"description": "Win a street race.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B4",
"displayName": "Netrunner",
"description": "Hack ten enemies in one fight.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B5",
"displayName": "Final Run",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
},
{
"name": "ACH_B6",
"displayName": "Rich Merc",
"description": "Hold a big pile of eddies.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,74 @@
{
"game": {
"gameName": "ELDEN RING",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_A1",
"displayName": "Rune Bearer",
"description": "Pick up your first rune from the grace.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A2",
"displayName": "Margit Down",
"description": "Defeat the warden of the gate.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A3",
"displayName": "Roundtable Guest",
"description": "Visit the hold between worlds.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A4",
"displayName": "Smith Friend",
"description": "Upgrade a weapon to a high level.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A5",
"displayName": "Mounted Hunter",
"description": "Slay a foe from horseback.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A6",
"displayName": "Great Rune",
"description": "Restore a great rune.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A7",
"displayName": "Deep Delver",
"description": "Explore a deep underground city.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
},
{
"name": "ACH_A8",
"displayName": "Lord of Lands",
"description": "Gather every legendary talisman.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Stray",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_F1",
"displayName": "Cat Nap",
"description": "Sleep in a sunny spot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F2",
"displayName": "Rooftop Walker",
"description": "Cross the roofs of the old slum.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F3",
"displayName": "Robot Friend",
"description": "Make a friend of a small robot.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F4",
"displayName": "Meow Choir",
"description": "Meow in front of ten robots.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F5",
"displayName": "Fish Lover",
"description": "Find a cold bowl of fish.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
},
{
"name": "ACH_F6",
"displayName": "Secret Paw",
"description": "Open every hidden door.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Cuphead",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_E1",
"displayName": "Contract Signed",
"description": "Take the deal at the casino.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E2",
"displayName": "Run and Gun",
"description": "Pass a platform stage.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E3",
"displayName": "Boss Rush",
"description": "Defeat five bosses.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E4",
"displayName": "Devil Beaten",
"description": "Complete the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E5",
"displayName": "Coin Collector",
"description": "Find every coin of a world.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
},
{
"name": "ACH_E6",
"displayName": "Perfect Parry",
"description": "Parry twelve pink shots in a row.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Disco Elysium - The Final Cut",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_C1",
"displayName": "Fresh Case",
"description": "Wake up in the hostel room.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C2",
"displayName": "Good Listener",
"description": "Talk to every skill voice.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C3",
"displayName": "Thought Cabinet",
"description": "Fill all slots of the cabinet.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C4",
"displayName": "Case Closed",
"description": "Finish the main story.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C5",
"displayName": "Best Friend",
"description": "Win the trust of your partner.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
},
{
"name": "ACH_C6",
"displayName": "Sweet Tunes",
"description": "Hear every song on the radio.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,58 @@
{
"game": {
"gameName": "Slay the Spire",
"gameVersion": "1",
"availableGameStats": {
"achievements": [
{
"name": "ACH_D1",
"displayName": "Spire Climber",
"description": "Reach the top of the first act.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D2",
"displayName": "Card Hoarder",
"description": "Own forty cards in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D3",
"displayName": "Relic Fan",
"description": "Hold ten relics in one run.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D4",
"displayName": "Silent Hunter",
"description": "Win a run as the second hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D5",
"displayName": "Cold Heart",
"description": "Win a run as the third hero.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
},
{
"name": "ACH_D6",
"displayName": "Pure Deck",
"description": "Win a run with a tiny deck.",
"hidden": 0,
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
}
]
}
}
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Mega Man Legacy"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-mega-man-legacy.json"
},
"priority": 4
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Ghost of Tsushima"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-tsushima.json"
},
"priority": 4
}
@@ -0,0 +1,19 @@
{
"request": {
"method": "POST",
"urlPath": "/igdb/games",
"bodyPatterns": [
{
"contains": "search \"Ghost of Yōtei"
}
]
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "igdb-game-search-psn-yotei.json"
},
"priority": 4
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/auth/authorize"
},
"response": {
"status": 302,
"headers": {
"Location": "com.scee.psxandroid.scecompcall://redirect/?error=invalid_grant"
}
},
"priority": 10
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/auth/authorize",
"headers": {
"Cookie": {
"contains": "npsso=local-npsso"
}
}
},
"response": {
"status": 302,
"headers": {
"Location": "com.scee.psxandroid.scecompcall://redirect/?code=local-code&cid=local"
}
},
"priority": 5
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/gamelist/v2/users/me/titles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-gamelist.json"
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"request": {
"method": "POST",
"urlPath": "/psn/auth/token"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-token.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-NPWR90001_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-NPWR90002_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-earned-NPWR90001_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophies-earned-NPWR90002_00.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/titles/trophyTitles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophy-title-sets.json"
}
}
@@ -0,0 +1,13 @@
{
"request": {
"method": "GET",
"urlPath": "/psn/trophy/v1/users/me/trophyTitles"
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "psn-trophy-titles.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
"queryParameters": {
"gameid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-global-percentages-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-player-achievements-646570.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1091500"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1091500.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1245620"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1245620.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "1332010"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-1332010.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "268910"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-268910.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "632470"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-632470.json"
}
}
@@ -0,0 +1,18 @@
{
"request": {
"method": "GET",
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
"queryParameters": {
"appid": {
"equalTo": "646570"
}
}
},
"response": {
"status": 200,
"headers": {
"Content-Type": "application/json"
},
"bodyFileName": "steam-schema-646570.json"
}
}
-5
View File
@@ -89,11 +89,6 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
<scope>runtime</scope>
</dependency>
<!-- testing -->
<dependency>
+455 -75
View File
@@ -1,40 +1,106 @@
# 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.
Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It syncs the user's Steam library and
the linked PlayStation account, 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:
To Be Played is split into four 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 |
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
## Features
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
sync. Resilience4j rate-limits every Steam call and tries the owned games up to three times (never again on a 4xx;
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
sync. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
once with an empty fallback (`steamApiOnce`); store, release and global-percentage calls not at all. Caffeine caches
player summaries (`steamPlayerSummary`), IGDB searches and the landing numbers, not game counts. A sync commits its
claim (`RUNNING`, with `sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes`
can be taken over; its own finish then changes nothing.
- **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`,
- **Picker**: seven rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
`NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played
IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR`
("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice),
`STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that
company, developer credits first; publishers are never anchors, so a big publisher adds at most one card),
`FRIENDS` (for each followed user with a public profile, one backlog game of the user that the friend finished or
played 5 h or more, strongest signal first; a finish and playtime need the `SUMMARY` or `STATUS` profile section (`FINISHED` alone lists only 12
games); `DROPPED`, hidden and removed games never count; the friend's current
display name, never an id),
`LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the
rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is
left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game
(`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill
(`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over
time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for
`SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`).
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from 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
```mermaid
flowchart LR
NEXT_UP --> Rows
SERIES --> Rows
SIMILAR --> Rows
STUDIO --> Rows
FRIENDS --> Rows
LONGEST --> Rows
RANDOM --> Rows
Rows["up to 3 games per row (ROW_SIZE), empty rows left out"] --> Filters["budget + exclude: every row; mood: RANDOM only"]
Filters --> Out["games + reason"]
```
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves
out what the followed user's profile hides (`public_user.sections`).
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. A note also carries `profileName` (the
subject's current name while the profile is public, else null), a detail line its `appid`, and each followed person
in `GET /api/follows` `lastActivityAt` (the newest digest note the caller got about them): the Frontend links the feed
to the game and to the profile view of that game. `GET /api/library/games/{appid}/preview` gives the catalog data of
any game (for "not in your library yet"), `GET /api/public/profiles/library/games/{appid}` one listed game of a public
profile, filtered by the ticket's sections; a hidden, removed or not owned game answers the same `404`.
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
date added). `PUT /api/library/backlog` replaces the whole order under a row lock; ids not in the backlog are
ignored and games not sent go after the sent ones. A merge keeps the better position of the two games. Coming
games are in the list; `released=true` leaves them out.
- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or
achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
(`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a
year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily).
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
name without an edition suffix. Several same-name games and no year to decide: no link. 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.
all users, with up to 3 requests per game and 1 request every 2 seconds; a result is kept 30 days. A rate limit answer
(429 or 420) pauses the queue (its Retry-After, else 1 min doubling up to 30 min) and asks the same game again; the
queue is never dropped for it. Migration V47 asks
every stored match again once at boot, behind user requests; links set by an auditor are never touched.
- **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.
- **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
activity view and the "most played in the last two weeks" dashboard banner.
- **Finished from achievements or playtime**: `EndingAchievement` finds 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
@@ -43,6 +109,27 @@ To Be Played is split into three repositories:
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.
- **Question queue** (Gitea #32): `GET /api/library/questions` lists one open question per game: Start it? (BACKLOG with
playtime), Finished? (BACKLOG or PLAYING, playtime at least IGDB's normal time to beat, else 10 h; never for
IGDB-endless games), Dropped? (PLAYING, no Steam playtime in the last 2 weeks and no sign of play for 90 days) and
Rate it? (FINISHED or COMPLETED without a rating). The order is the priority. `OpenQuestion.of` is a pure rule over
`QuestionFacts`, which one SQL query loads for the whole library (`counted_user_game`, so a linked set is one card).
Every rule starts from the status, so a game the sync already finished or started drops out by itself. A "No" is
stored in `user_game_question` (V41): Start it? and Finished? come back after 5 more hours of playtime, Dropped?
after another 90 idle days, a Rate it? "Skip" never. `POST /api/library/questions/{appid}`
(`{question, yes, rating?}`) checks that the question is still open (`409 QUESTION_GONE`, `404 GAME_NOT_FOUND`,
`400 INVALID_ANSWER` for a yes to RATE without a rating) and then calls `BacklogService`, the same write path as the
game page. `GET /api/library/questions/count` uses the same code as the list, so the badge equals the cards.
```mermaid
flowchart LR
Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"]
Rule --> Card["question card"]
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
Sync["library sync"] --> LibraryActivityEvent --> Settle
Answer -->|no| Snooze["user_game_question"]
```
- **Hide games**: a user can hide a game, Steam or added by hand (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
@@ -54,24 +141,49 @@ To Be Played is split into three repositories:
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
`listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime
sums, most played, genres, moods and platforms still use every listed game.
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal,
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres,
moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never
started" and "under one hour": a set counts once, judged by its set total (also the dashboard "never played" and the
oldest unplayed game) and the trend's never played (`backlog_snapshot`); rows written before #43 keep the old value,
they cannot be rebuilt.
Platforms stay per edition: a Steam + PlayStation set counts on both platforms.
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
gets a list only when the ticket shares that section, or the `STATS` section and the key's group (`403 NOT_SHARED`;
`OVERVIEW` keys are for the owner only). The community list has one row per game with `libraries` and `minutes`,
never a user, and is cached 10 minutes like the numbers. A bad argument is `400 BAD_ARG`.
- **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.
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet`, on `GET /api/rewards/games/{appid}`, after every sync (`LibraryActivityEvent`, own transaction, a failure only logs) and
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 28 looks (`ShopItem`) in seven slots, including animated completed effects, page backgrounds and banner
backgrounds (V27 widens `reward_equipped.slot`); a `BUY` row proves ownership,
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's last achievement
unlock (100 % reached) is on or after `app.rewards.early-since` (else `COMPLETION_TOO_OLD`); a manual game itself never gets it.
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts; `earlySince` (ISO date, `app.rewards.early-since`) fills the date in the texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a
track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished
(`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in
a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too).
A set (`user_game_set`, see "Editions of one game") pays `STARTED`, `FINISHED`/`FINISHED_MANUAL` and `COMPLETED` once: a
game whose other edition was paid is no candidate, and two editions of a set give one candidate (the first event).
A `BUY` or `UNLOCK` row proves ownership,
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
claim of the profile ticket for visitors).
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
and a user can split a key off a set for themselves (see "Editions of one game"). A set shares one status, rating,
note and priority. Users can ask for a pair; the auditor queue holds user requests and the IGDB pairs not linked yet (`OPEN` only). Every link, unlink and dismissal goes to `admin_audit`
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
the actor's display name from Auth's account state push.
- **Wishlist**: synced from Steam with store price and discount.
@@ -80,10 +192,15 @@ To Be Played is split into three repositories:
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_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.
- **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
them story / extras / 100 %. Optional: without credentials the feature is off and the rest works.
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
default. A section the user
@@ -101,7 +218,7 @@ The token comes from Auth's `tbp_access` cookie or a `Bearer` header, whichever
(`USER < AUDITOR < PLATFORM_ADMIN`). A token without `aud` is refused. The claims are pinned in
`src/test/resources/contract/auth-core-contract.json`, the same file as in Auth; the tests build tokens only from it.
`POST`, `PUT` and `DELETE` need an `X-TBP-Client` or `Authorization` header (CSRF guard), and CORS allows only the
frontend origin.
frontend origin; preflight answers carry `Access-Control-Max-Age: 3600` (pinned by `ClientHeaderFilterTest`).
A token for another audience (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.
Every error answer of a controller has the body `{code, message}` (a 401 or 403 of the security filters has none); a refused request is `400 VALIDATION_FAILED` with
@@ -131,40 +248,73 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| Method | Path | Auth |
|---|---|---|
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS`) | JWT |
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT |
| `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT |
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/games/{appid}/preview` (`{appid, name, imageUrl, genres, timeToBeat}`: catalog data of any game, a merged key answers with the kept key; `404 GAME_NOT_FOUND`) | JWT |
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
| `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR |
| `DELETE` | `/api/library/games/{appid}/set` (the user splits the key off its set for themselves; `204`, `404 NOT_LINKED` when no other listed edition is in the user's set) | JWT |
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
| `GET` | `/api/library/questions`, `/api/library/questions/count` (`{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, lastActivity}]}`, `{count}`) | JWT |
| `POST` | `/api/library/questions/{appid}` (`{question, yes, rating?}`; `200 {next}`, `400 INVALID_ANSWER`, `404 GAME_NOT_FOUND`, `409 QUESTION_GONE`) | JWT |
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
| `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT |
| `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `GET` | `/api/library/game-search?q=` (IGDB search for "add a game", `q` 2-100 characters; `{available, results: [{appid, inLibrary, ...}]}`; `502 IGDB_FAILED`) | JWT |
| `POST` | `/api/library/games` (`{igdbId, platform, status, playtimeMinutes?}`; `201 {appid}`, `400 UNKNOWN_GAME`, `409 ALREADY_IN_LIBRARY`, `503 IGDB_OFF`) | JWT |
| `PUT` | `/api/library/games/{appid}/manual` (`{platform, playtimeMinutes}`, MANUAL and PSN rows, a PSN row keeps its playtime; `204`, `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
| `DELETE` | `/api/library/games/{appid}` (soft remove, same answers) | JWT |
| `GET` | `/api/library/sync-changes` (`{syncedAt, startedBy, seen, importedGames, changes[]}` of the last sync; `204` when none) | JWT |
| `PUT` | `/api/library/sync-changes/seen` (`{syncedAt}`; idempotent; `204`) | JWT |
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT |
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt, reviewImportId}` and a sync starts, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped; `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | 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/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
| `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/achievements/summary` | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
| `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT |
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
| `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login |
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
| `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | login |
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked}]}`, `displayName` optional, at most 100 characters, kept in `account_name` for the audit log; POST updates, PUT replaces the whole set; `204`) | service token |
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
@@ -174,9 +324,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
- 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)
- Spring Boot Actuator (`/actuator/health` only)
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
- CI: Forgejo Actions, multi-stage Docker image
- CI: Gitea Actions (`.gitea/workflows/publish-core.yml`), multi-stage Docker image
## Architecture
@@ -194,13 +344,17 @@ flowchart LR
Stats["stats"]
Version["version"]
Account["account"]
Follow["follow"]
Inbox["inbox"]
Rewards["rewards"]
Steam["steam_adapter"]
Igdb["igdb_adapter"]
Speedrun["speedrun_adapter"]
Psn["psn_adapter"]
LibService --> Steam
LibService --> Igdb
LibService --> Speedrun
LibService --> Psn
end
Browser -->|"tbp_access cookie"| Security
Rewards --> Db
@@ -210,37 +364,50 @@ flowchart LR
Igdb --> IgdbApi["IGDB API"]
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
LibRepo --> Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game, account_name, blocked_user, deleted_user"| Db
Auth -->|"service token<br/>/internal/accounts"| Account
Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
```
| Module | Content |
|---|---|
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
| `library` | Sync (Steam and PSN), backlog, manual games, imports, the PlayStation link, 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 |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
| `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge |
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
| `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 |
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles, trophy sets and lists |
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
reads `deleted_user` and `blocked_user`. A module that moves to its own service takes these reads along as calls.
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls.
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
a column of `user_game` drops this view first, then `listed_user_game`.
a column of `user_game` drops the views built on it first: `user_game_entry_playtime`, `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`.
The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member`
is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source
and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key.
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 `library` may use `steam_adapter`, `igdb_adapter` and
`speedrun_adapter`.
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
`speedrun_adapter` and `psn_adapter`.
- No cycles between `modules`, `shared` and `configuration`.
- `api` never uses `repo`, and `repo` never uses `api`.
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
@@ -254,9 +421,11 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
### Sync flow
1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.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/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
1. `GET /api/library/games`, `/dashboard-games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
when the last success is older than 20 hours (`app.library.sync-ttl-hours`) and the last try older than 60 minutes
(`login-retry-minutes`, also after a failure), or when it was for another Steam ID.
`POST /api/library/sync` starts one at once, but at most every 60 seconds (`force-sync-cooldown-seconds`),
else `429` with `Retry-After`.
2. A nightly job (23:55, Europe/Berlin) syncs every account used in the last 14 days, one after the other. A
request without a `steamId` claim (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 to `backlog_snapshot`, also for users without Steam, and
@@ -267,33 +436,73 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
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. 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
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. 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.
A game the user added by hand for PC, Steam, Mac or Linux (or with no platform), whose IGDB game is the IGDB
game of a Steam appid the user owns for the first time (`game_edition`), becomes that Steam row. The row moves
from minus its IGDB id to the appid and keeps status, rating, notes, priority, status history, backlog
position, questions, set rows, PlayStation links and paid game rewards. Playtime and last played come from
Steam. A hand-added game on another platform stays and joins the Steam game's set. A user with such a game
gets the IGDB edition refresh before the save, so the mapping is known in time.
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.
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (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 PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
The frontend polls `GET /api/library/sync-status` until the run ends.
Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
`FollowDigestService` writes one inbox note per follower from the new sync result (`ADDED`, `ACHIEVEMENTS`, `STATUS`
and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kinds that the profile hides (`public_user.sections`)
are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10
minutes; it never takes a sync result, which may still wait for its achievements.
`ALL_UNLOCKED`: the sync unlocked the last Steam achievement of a game that is not COMPLETED; Core never sets COMPLETED itself, the user confirms it in the sync details.
```mermaid
sequenceDiagram
participant W as LibrarySyncWorker
participant P as PsnSyncStep + PsnTrophyStep
participant A as AchievementSyncService
participant F as FollowDigestService
participant D as Db
W->>P: run (titles, trophy summaries, changed lists)
P->>D: save title play (user_psn_title)
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
W-->>A: LibrarySyncedEvent
A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED)
A-->>F: LibraryActivityEvent
F->>D: lock follow_digest, count sync + hand changes
F->>D: inbox_note per follower (public, not blocked, not deleted)
F->>D: move cursor
```
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.
### Background work
Background work runs on two pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it):
Background work runs on three pools (`AsyncConfig`; every `@Async` names one, ArchUnit checks it):
- The default pool (4 threads, queue 100) runs the syncs and the time-to-beat and mood refreshes a page view
starts. A page view queues only games without a fresh row. When the queue is full, the task is dropped with
a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user
starts it again.
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page
views' threads. A task is small (a user or a few appids), and none is dropped.
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and the speedrun
refreshes (`@Async`). They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take
the page views' threads. A task is small (a user or a few appids), and none is dropped.
- The speedrun drainer (`speedrunExecutor`, 1 thread) works the speedrun.com queue (`SpeedrunService`).
Imports have their own two threads, and `game_import` is their queue. Scheduled jobs (nightly sync, image repair,
import watchdog) run on the scheduler.
Imports run in two lanes, `game-import-match` and `game-import-save`, 1 thread each, queue 1; `game_import` is their
queue. Scheduled jobs run on the scheduler: nightly sync 23:55, image repair 01:30, rewards settle 02:30, inbox purge
03:15, merge-undo purge 03:45, import watchdog every minute, credit backfill every 30 s, follow digest every 10 min.
```mermaid
flowchart LR
@@ -301,10 +510,12 @@ flowchart LR
Sync["sync request"] --> Pool
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
View -->|"speedrun"| FollowUp
FollowUp --> Drainer["speedrunExecutor<br/>1 thread"]
Pool --> Apis["Steam, IGDB, speedrun.com"]
FollowUp --> Apis
Drainer --> Apis
Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"]
Cron["scheduler"] --> Jobs["nightly sync, image repair, import watchdog"]
Cron["scheduler"] --> Jobs["nightly sync, image repair, rewards settle, inbox purge, merge-undo purge, import watchdog, credit backfill, follow digest"]
```
Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks
@@ -386,17 +597,73 @@ like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edit
`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own
IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`.
- `game_link(appid, set_id)`: the keys that are one game, for every user. A set is every row with one `set_id`.
An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
editions refresh and once at start for the games already there (`linkExisting`). No auditor, so no audit row. A pair
in `DISMISSED` (an auditor dismissal or an unlink) is never linked again, also not through a third edition: a link
is skipped when a dismissed pair lies between the two sets. The link is global: a wrong IGDB key groups
the game for every user until an auditor unlinks it.
- **Split** (`DELETE /api/library/games/{appid}/set`): `user_game_split(user_id, appid)` makes the key stand alone for
this user, only while another listed edition is in the user's set. No sync or link touches it, so it stays split.
The view `user_game_set(user_id, appid, set_id)` is `game_link` for the user's rows minus the split keys; every
reader of a user's set (`counted_user_game`, the editions of the detail, the `source` filter, the questions, rewards)
reads it. There is no undo of a split yet.
- **One status per set** (V42 triggers, like V14, so no code path can forget it): a change of `status`,
`status_set_by_user`, `rating`, `notes` or `priority` on one row is copied to the other rows of the user's set,
also hidden and removed ones (`user_game_set_share`; `pg_trigger_depth()` stops the echo). When rows join a set (a new
row, a new link, a merge of sets), `align_user_game_set` applies the join rule: the winner is the counted edition
(listed first, then status rank, playtime, older `first_seen_at`, lower appid); its status wins, `status_set_by_user`
is true if any row has it, rating and priority fall back to the first row that has one, and the distinct notes are
joined with a blank line, cut to 2000. A row that changes is first copied to `user_game_set_backup` (the first copy
wins; kept until the account is deleted). The status log of a join is no hand change (`by_user` false), and a row that
takes a finished status takes the set's first finish time, so "finished per year" does not move.
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry) and the
community stats of the landing page. The playtime of a card, the playtime sort and the stats is the set total (`user_game_entry_playtime`, V45, see "Detail per platform"). Pile Points pay a set once (see "Pile Points"), and the
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
The library query joins `user_game_entry_playtime` on every page (about +4 ms per page); upgrade path: join it only for the playtime sort or the PLAYING status, with a second query.
- **Detail per platform** (V44): the PSN step stores platform, minutes and last played per title in `user_psn_title`
(`LINKED` and `STEAM_OWNED`, every sync, whatever row the title is on); the view `psn_title_play` resolves a merged
key through `game_alias`. `PlatformPlay.lines` is the one rule for the detail lines: a Steam or manual row is one
line, a PlayStation row is its titles (or one line from the row before the first sync after V44), a manual row is
replaced by the titles of its own platform, and every other title linked to a Steam or manual row adds its
PlayStation line. The total is their sum; the view `user_game_entry_playtime` (V45) is the same rule in SQL, a test
compares both. The card total (`GameLinkRepository.entryPlaytimes`), the playtime sort of the library, `sumPlaytime`
and the stats read that view; it has no correlated subquery on purpose (it made a library page cost 2 s). Each line carries its own
achievement or trophy progress; the full list stays on the edition's own detail page. Still per row, not per
platform: the playtime history (PSN minutes on a Steam row are not in it) and rewards (Steam only). Until a user's next sync after V44, PSN-on-Steam
lines are missing and Steam's last played is empty.
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
(size of the set in the user's library, `null` below 2).
(size of the set in the user's library, `null` below 2), `platforms` (`source` + `platform` per edition, for
the icons of the card) and `totalPlaytimeMinutes`.
```mermaid
flowchart LR
Edition["game_edition (IGDB same_game_id)"] -->|"linkSameGames"| Link["game_link (global sets)"]
Auditor["auditor link / unlink"] --> Link
Link --> Set["user_game_set"]
Split["user_game_split (user splits)"] -->|"minus"| Set
Set --> Counted["counted_user_game"]
Counted --> Member["user_game_entry_member"]
Member --> Platform["user_game_entry_platform"]
Platform -->|"platforms[]"| Card["library card, lists"]
EntryPlaytime -->|"totalPlaytimeMinutes"| Card
EntryPlaytime --> Stats["library sort, stats, public profile"]
Member --> EntryPlaytime["user_game_entry_playtime (V45)"]
Title["user_psn_title play (V44)"] -->|"psn_title_play"| EntryPlaytime
Title -->|"platforms[] of the detail"| Detail["game detail"]
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
Row -->|"update: user_game_set_share"| Row
Row -.->|"old values"| Backup["user_game_set_backup"]
Set --> Rewards["rewards: a set pays once"]
```
### Deleted accounts
@@ -405,14 +672,20 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
user ids, which removes the rows of users Auth does not know (refused for more than
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
The purge also removes the user's `account_name` row and link requests (`game_link_request`).
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits
(`user_game_split`) and set backups (`user_game_set_backup`).
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
### Blocked users and Steam ID changes
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. The access token still works in Core until
it expires (max 15 min). A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26), of the public profile or of the profile tiles (`publicProfile`, `profileSections`, #27); its nightly job sends every user (`PUT`). A blocked user is in
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
only the blocks. A new Steam ID or no Steam ID takes the user out of the nightly sync; the next visit syncs
the ID of the token (Core's copy changes only then, so that sync sees the change and drops the old account's games). A visit with an access token that still has the old ID (max 15 min) can sync it once
more and put the user back on the nightly list; the nightly `PUT` at 04:15 takes the user off again, so at most one
nightly sync (23:55) runs with the old ID. Unblock and the nightly `PUT` lift the block.
@@ -428,12 +701,22 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games
`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
`STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
or removed (`DELETE /api/library/games/{appid}`, soft: it sets `removed_at`, the row keeps status, rating, notes
and history) and restored (`POST /api/library/games/{appid}/restore`, see the diagram). 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; an auditor then merges the two
(see "Merging duplicates").
```mermaid
stateDiagram-v2
[*] --> Listed: add / sync
Listed --> Removed: DELETE /games/{appid} (MANUAL, PSN)
Removed --> Listed: POST /games/{appid}/restore or Undo
Removed: removed_at set, data kept
Removed: PSN sync skips the row
```
### Imports
Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one
@@ -470,10 +753,83 @@ stateDiagram-v2
FAILED --> UNDONE: DELETE /games (the games added before it failed)
```
The undo deletes the games of the import that are still `MANUAL` (a game the Steam sync took over stays). A removed
The undo deletes the games of the import that are still `MANUAL` or `PSN` (a game the Steam sync took over stays). A removed
row the import brings back gets no `import_id`: it holds rating, notes and history from before, so the undo keeps it;
`undo-check` counts those with a rating, notes, priority or a status change after the save.
### PlayStation link
`modules/psn_adapter` calls PlayStation's unofficial API (the public client of the PlayStation app, as the `psn-api`
project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` holds the use cases:
- **Link** (`POST /api/library/psn-account`): the NPSSO (the user's `npsso` cookie from playstation.com) goes to
`/authorize` (the redirect is not followed: `Redirect.NEVER`), the `code` in its `Location` header to `/token`.
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
wrong length stops Core at start. A changed key makes stored links unreadable: the sync skips PlayStation and the user links again.
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
refresh token keeps the stored expiry; a new one stores its own. The Settings page shows the date. The inbox gets one
note `PSN_EXPIRING` when the link ends within `app.psn.expiry-warning-days` (2) and one note `PSN_EXPIRED` when it has
ended or PlayStation refuses the refresh token (also an unreadable token). `user_psn_account.expiry_note` keeps what
was written, so a note comes once per link; a new link resets it. The library module writes `inbox_note` by SQL, as
the follow module does. An ended link makes the sync skip PlayStation until the user pastes a new NPSSO.
- **Sync**: linking starts a sync (`LibrarySyncService.startAfterLink`). Inside the force-sync cooldown or while a sync runs, one sync per user is scheduled for the end of the cooldown (in memory, lost on restart; the next login sync covers it).
After that the PSN step (`PsnSyncStep`) runs in every library sync (login, nightly, manual) after the Steam fetch.
One refresh call, then `gamelist/v2` (200 per page, at most 1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no
PS4/PS5 suffix) and joins the PS4 and PS5 versions of one `concept.id` into one line (name and platform of the most
played version, playtime summed, newest `lastPlayedDateTime`). `user_psn_title` remembers each title by its
`psn_key` (`c<conceptId>`, else `t<titleId>`) as `LINKED` (with the library key) or `DISMISSED`:
- A **new** title (not in the table, not in an open PSN import) is searched in IGDB. Same name and a version on the
line's platform: `ManualGameService.addGame` as `PSN`, no review, an `ADDED` change. The same game under another
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
- The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in
`sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the
follow digest need no change.
- PlayStation and IGDB are called outside the sync transaction; the step catches every error, so PlayStation never
fails the Steam part. A user with a PlayStation link and **no Steam ID** syncs too (`user_steam_sync.steam_id` is
nullable): the Steam part is skipped, the same result, banner and digest apply.
The client cannot start a `PSN` import with its own rows.
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
move it back. The nightly loop is sequential.
- **Trophies** (V40, `PsnTrophyStep`, called at the end of `PsnSyncStep.run`, outside the sync transaction, never throws).
It reuses the access token of the title part (`FetchResult.Titles` hides it in `toString`). Own tables
`psn_trophy_set`, `psn_trophy`, `user_psn_trophy_set`, `user_psn_trophy` and the cache `psn_title_trophy_set`, so the
Steam tables and the rewards (Steam only) stay untouched.
- **Summary**: 1-2 calls `trophy/v1/users/me/trophyTitles` give every set with defined and earned counts,
`trophySetVersion` and `lastUpdatedDateTime`. Hidden (`hiddenFlag`) sets, PS3/Vita-only sets and sets of unknown or
DISMISSED titles are not stored. A summary row is saved for every mapped set. **Gains** (`ACHIEVEMENTS` changes) come
from the earned total only; the first time a set is stored is a baseline.
Only a **complete** summary (sets returned = the API's `totalItemCount`) removes the user's stored sets that are no
longer in it (another account was linked); a partial one, after a broken page, removes nothing. Unlink deletes all of
the user's trophy rows.
- **Mapping**, two paths, the log line `PSN trophies user=… mapped by title id=N by name=M` shows which one worked.
(1) Exact: `titles/trophyTitles?npTitleIds=` (5 ids per call) answers title id to set; cached globally, "no set" is
asked again after 30 days. Not confirmed against the real service: a 4xx or an empty answer falls through to (2)
Name + platform, same account only: the clean name equals that of exactly one linked title and its platform is in the
set's platform list. Two games with the same name: the set stays unmapped. The set counts for the merge target of the game.
- **Lists only when changed**: the global trophy list when the stored `list_version` differs from `trophySetVersion`;
the user's earned list (also gives the rarity) when `list_updated_at` differs from `lastUpdatedDateTime`; a set with
0 earned needs no earned call.
- **Budget** `app.psn.trophy-calls-per-run` (30, about 2 min at 1 request / 4 s; the sync counts as stale after 5 min)
covers title id lookups and lists. Newest sets first; the rest follow in later syncs (the detail page then says the
list loads with the next sync). Three errors in a row stop the list calls, the summaries are still saved.
- **Presentation**: `perfect()` = platinum or all earned; one entry per set, one per game for the counts (perfect set,
else best percent). A hidden trophy that is not earned has no name and no description. Stored image URLs must be `https://`.
A sync costs 2 calls (token, titles) plus the summary and the changed lists.
## Run locally
### Local development without keys (profile `local`)
@@ -488,7 +844,7 @@ other, then start them in this order (Core also starts without Auth and finds it
3. **Frontend**: in `ToBePlayed-Frontend`, `npm install`, then `npm run dev`, and open `http://localhost:5173`
(`localhost`, not `127.0.0.1`, so the browser sends the login cookies to every local port). The development
values must be `VITE_API_URL=http://localhost:8080` and `VITE_CORE_API_URL=http://localhost:8090`.
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 8
4. **Log in** with `dev@localhost` / `password123`. On the dashboard, start a Steam sync: the library fills with 30
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
What the `local` profile does:
@@ -507,6 +863,11 @@ What the `local` profile does:
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The link starts
a sync: Ghost of Yōtei and Mega Man Legacy Collection match exactly and are added by themselves. Ghost of Tsushima (PS4
and PS5 version as one line, "DIRECTOR'S CUT" in the name) and Monster Hunter Wilds (IGDB's recording does not know it)
wait in the import review (`/import`). The local profile has a fixed token key. The trophy stubs (Ghost of Yōtei PS5
by title id, Mega Man PS4 by name, one PS3 and one hidden set) are fake `NPWR9…` ids in `local/wiremock`.
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
@@ -524,10 +885,11 @@ Needs Java 25, Docker and your own keys.
```
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`.
service listens on `http://localhost:8090`. Health: `/actuator/health` (the only open actuator endpoint).
In the `dev` profile a request without an `Authorization` header gets a dummy user, so the API is usable without a
running Auth service.
running Auth service. There is no `application-dev.yml`: `dev` only swaps the JWT filter
(`DevJwtAuthenticationFilter`, `@Profile("dev")`); everything else is `application.yml`.
Configuration comes from environment variables:
@@ -537,7 +899,25 @@ Configuration comes from environment variables:
| `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 |
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
| `APP_FRONTEND_URL` | CORS origin, default the production host |
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the purge and prune only count |
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
### Request timing log
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
that leaves the filter chain is logged as `500`.
### Database connection settings
Every Core connection runs with `plan_cache_mode = force_generic_plan` and `prepareThreshold=1`
(`spring.datasource.hikari` in `application.yml`). Planning was 94% of the Dashboard DB time and prod never reused a
plan (few users, a new connection every 30 min). With a cached generic plan the Dashboard burst dropped from 443 to
229 ms on 2 CPUs (#49). Cost: the first burst on fresh connections costs about 100 ms more (cold start: 1101 -> 1205
ms), and a generic plan ignores parameter values.
## Tests
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
/**
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
* asks again.
*/
@Configuration
@@ -43,4 +43,14 @@ public class AsyncConfig {
executor.setThreadNamePrefix("sync-follow-up-");
return executor;
}
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
@Bean(name = BackgroundPools.SPEEDRUN)
public ThreadPoolTaskExecutor speedrunExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(1);
executor.setMaxPoolSize(1);
executor.setThreadNamePrefix("speedrun-");
return executor;
}
}
@@ -8,6 +8,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import java.time.Duration;
import java.util.concurrent.TimeUnit;
@Configuration
@@ -17,11 +18,16 @@ public class CacheConfig {
@Bean
public CacheManager cacheManager() {
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "steamPlayerSummary",
"igdbSearch");
CaffeineCacheManager cacheManager = new CaffeineCacheManager("publicStats", "publicStatGames",
"steamPlayerSummary", "igdbSearch");
cacheManager.setCaffeine(Caffeine.newBuilder()
.expireAfterWrite(10, TimeUnit.MINUTES)
.maximumSize(1000));
// The real limit after a block or a role change: the state call evicts it, a missed call heals after 30 s.
cacheManager.registerCustomCache("loginStates", Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofSeconds(30))
.build());
return cacheManager;
}
}
@@ -0,0 +1,54 @@
package core_service.configuration;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.jspecify.annotations.NonNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import org.springframework.web.servlet.HandlerMapping;
import java.io.IOException;
/**
* One INFO line per request: method, handler pattern, status, duration. The pattern (e.g. {@code /api/games/{appid}})
* stands for the path, so the line holds no ids, query, user or credentials. A request no handler took shows {@code -}.
*/
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestTimingFilter extends OncePerRequestFilter {
private static final Logger LOG = LoggerFactory.getLogger(RequestTimingFilter.class);
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain
) throws ServletException, IOException {
long start = System.nanoTime();
int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
try {
filterChain.doFilter(request, response);
status = response.getStatus();
} finally {
// An exception that leaves the chain ends as a 500; the container sets it only after this filter.
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
LOG.info("{} {} {} {} ms", request.getMethod(), pattern != null ? pattern : "-", status,
(System.nanoTime() - start) / 1_000_000);
}
}
/**
* The health check calls the actuator every 30 s; its lines would flood the log.
*/
@Override
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
return request.getRequestURI().startsWith("/actuator/");
}
}
@@ -1,5 +1,6 @@
package core_service.configuration.security;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.UserPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -17,12 +18,16 @@ import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.Optional;
/**
* A blocked user and a token issued before the last role change of its user count as not logged in.
*/
@Component("jwtAuthenticationFilter")
@Profile("!dev")
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
private final LoginStateCheck loginStateCheck;
@Override
protected void doFilterInternal(
@@ -34,6 +39,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
Optional<UserPrincipal> principal = AccessTokens.candidates(request)
.map(jwtService::parseLoginToken)
.flatMap(Optional::stream)
.filter(each -> loginStateCheck.state(each.getIdAsLong()).accepts(each.issuedAt()))
.findFirst();
if (principal.isPresent() && SecurityContextHolder.getContext().getAuthentication() == null) {
UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(
@@ -58,7 +58,8 @@ public class JwtService implements ProfileTicketVerifier {
if (userId == null) {
return Optional.empty();
}
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims)));
return Optional.of(new UserPrincipal(userId, claims.get("steamId", Long.class), role(claims),
claims.getIssuedAt() == null ? null : claims.getIssuedAt().toInstant()));
} catch (Exception e) {
return Optional.empty();
}
@@ -33,6 +33,7 @@ public class SecurityConfig {
"/actuator/health", // Docker health check; details are off
"/error",
"/api/stats/public",
"/api/stats/public/games",
"/api/public/**", // Public profiles; PublicProfileController checks the ticket
"/api/version" // Build of the running image, shown in the settings
};
@@ -64,7 +64,9 @@ public class InternalAccountController {
private static List<AccountState> toStates(AccountStatesRequest request) {
return request.accounts().stream()
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(), account.blocked()))
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
account.blocked(), account.roleChangedAt(), account.listed(), account.publicProfile(),
account.profileSections()))
.toList();
}
@@ -4,14 +4,21 @@ import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.time.Instant;
import java.util.List;
public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> accounts) {
/**
* @param displayName null from an Auth that does not send it yet
* @param displayName null from an Auth that does not send it yet
* @param roleChangedAt null from an Auth that does not send it yet
* @param listed null from an Auth that does not send it yet
* @param publicProfile null from an Auth that does not send it yet
* @param profileSections the profile parts the user shows; null = all, or an Auth that does not send them. Unknown names
* are dropped by {@code AccountState}
*/
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
boolean blocked) {
boolean blocked, Instant roleChangedAt, Boolean listed,
Boolean publicProfile, List<String> profileSections) {
}
}
@@ -1,10 +1,26 @@
package core_service.modules.account.domain;
import java.time.Instant;
import java.util.List;
import java.util.Set;
/**
* What Core copies from Auth for one user.
*
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
* @param displayName null when Auth did not send one; Core then keeps the stored name
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
* @param listed null from an Auth that does not send it; Core then keeps the stored listing
* @param publicProfile null from an Auth that does not send it; Core then keeps the stored row and its sections
* @param profileSections the profile parts the user shows; null = all shown
*/
public record AccountState(long userId, Long steamId, String displayName, boolean blocked) {
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
Boolean listed, Boolean publicProfile, List<String> profileSections) {
// One odd entry must not fail the push of every user, so unknown names are dropped, not refused.
private static final Set<String> SECTIONS = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
public AccountState {
profileSections = profileSections == null ? null : profileSections.stream().filter(section -> section != null && SECTIONS.contains(section)).toList();
}
}
@@ -19,15 +19,15 @@ import java.util.Map;
@RequiredArgsConstructor
public class AccountCleanupRepository {
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot",
"user_steam_sync", "game_merge_undo_user", "user_game", "game_link_request", "account_name", "game_import", "reward_ledger", "reward_equipped",
"blocked_user", "deleted_user");
// Children before user_game (several would also go by its cascade), the tombstones last.
static final List<String> TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
"game_import", "blocked_user", "deleted_user", "account_name");
"game_import", "user_psn_account", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
private final NamedParameterJdbcTemplate jdbc;
@@ -36,7 +36,8 @@ public class AccountCleanupRepository {
}
/**
* Also takes the users off the nightly sync list.
* Also takes the users off the nightly sync list, ends their follows, both ways, without a note, and drops their
* stored PlayStation token at once.
*/
public void addDeleted(Collection<Long> userIds) {
if (userIds.isEmpty()) {
@@ -45,6 +46,10 @@ public class AccountCleanupRepository {
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing",
userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new));
jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds));
jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds));
jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds));
jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds));
jdbc.update("delete from user_psn_account where user_id in (:ids)", ids(userIds));
}
/**
@@ -88,6 +93,7 @@ public class AccountCleanupRepository {
for (String table : TABLES) {
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
}
jdbc.update("delete from inbox_note where subject_user_id in (:ids)", ids(userIds));
// The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
jdbc.update("""
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
@@ -1,16 +1,20 @@
package core_service.modules.account.repo;
import core_service.modules.account.domain.AccountState;
import core_service.shared.security.LoginState;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.Collection;
import java.util.List;
/**
* Blocks in blocked_user, the display name in account_name, and what a Steam ID change means for user_steam_sync.
* Blocks in blocked_user, listing in listed_user, public profiles in public_user (and the follows that end with them),
* role changes in account_role_change, the display name in account_name, and what a Steam ID change means for
* user_steam_sync.
* Plain SQL, because the library module owns the entities.
*/
@Repository
@@ -27,11 +31,23 @@ public class AccountStateRepository {
+ "on conflict (user_id) do update set display_name = excluded.display_name",
params.addValue("displayName", state.displayName()));
}
if (state.roleChangedAt() != null) {
jdbc.update("insert into account_role_change (user_id, changed_at) values (:userId, :roleChangedAt) "
+ "on conflict (user_id) do update set "
+ "changed_at = greatest(account_role_change.changed_at, excluded.changed_at)",
params.addValue("roleChangedAt", Timestamp.from(state.roleChangedAt())));
}
if (state.blocked()) {
jdbc.update("insert into blocked_user (user_id) values (:userId) on conflict do nothing", params);
} else {
jdbc.update("delete from blocked_user where user_id = :userId", params);
}
if (Boolean.TRUE.equals(state.listed())) {
jdbc.update("insert into listed_user (user_id) values (:userId) on conflict do nothing", params);
} else if (Boolean.FALSE.equals(state.listed())) {
jdbc.update("delete from listed_user where user_id = :userId", params);
}
applyPublicProfile(state, params);
if (state.blocked() || state.steamId() == null) {
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
params);
@@ -44,16 +60,61 @@ public class AccountStateRepository {
}
}
// A blocked user ends follows in both directions without a note; a user who goes private tells each follower.
private void applyPublicProfile(AccountState state, MapSqlParameterSource params) {
if (state.blocked()) {
jdbc.update("delete from follow where user_id = :userId or followed_user_id = :userId", params);
jdbc.update("delete from public_user where user_id = :userId", params);
} else if (Boolean.TRUE.equals(state.publicProfile())) {
String[] sections = state.profileSections() == null ? null : state.profileSections().toArray(String[]::new);
jdbc.update("insert into public_user (user_id, sections) values (:userId, cast(:sections as varchar[])) "
+ "on conflict (user_id) do update set sections = excluded.sections",
new MapSqlParameterSource(params.getValues()).addValue("sections", sections));
} else if (Boolean.FALSE.equals(state.publicProfile())) {
jdbc.update("delete from public_user where user_id = :userId", params);
jdbc.update("""
with ended as (delete from follow where followed_user_id = :userId returning user_id)
insert into inbox_note (user_id, kind, subject_user_id, subject_name)
select e.user_id, 'VISIBILITY', :userId, (select display_name from account_name where user_id = :userId)
from ended e""", params);
}
}
public LoginState findLoginState(long userId) {
return jdbc.queryForObject("""
select exists(select 1 from blocked_user where user_id = :userId) as blocked,
(select changed_at from account_role_change where user_id = :userId) as changed_at""",
new MapSqlParameterSource("userId", userId),
(rs, rowNum) -> {
Timestamp changedAt = rs.getTimestamp("changed_at");
return new LoginState(rs.getBoolean("blocked"), changedAt == null ? null : changedAt.toInstant());
});
}
/**
* After this, exactly the blocked users of the list are blocked.
* After this, exactly the blocked users of the list are blocked, and the listed and public ones listed and public
* unless a state has no such flag.
*/
public void replaceAll(Collection<AccountState> states) {
states.forEach(this::apply);
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
if (blocked.isEmpty()) {
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
keepOnly("blocked_user", states.stream().filter(AccountState::blocked).map(AccountState::userId).toList());
if (states.stream().noneMatch(state -> state.listed() == null)) {
keepOnly("listed_user", states.stream().filter(AccountState::listed).map(AccountState::userId).toList());
}
if (states.stream().noneMatch(state -> state.publicProfile() == null)) {
keepOnly("public_user", states.stream().filter(state -> state.publicProfile() && !state.blocked())
.map(AccountState::userId).toList());
// apply() already told the followers of a user who went private.
jdbc.update("delete from follow f where not exists (select 1 from public_user p where p.user_id = f.followed_user_id)",
new MapSqlParameterSource());
}
}
private void keepOnly(String table, List<Long> userIds) {
if (userIds.isEmpty()) {
jdbc.update("delete from " + table, new MapSqlParameterSource());
} else {
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
jdbc.update("delete from " + table + " where user_id not in (:ids)", new MapSqlParameterSource("ids", userIds));
}
}
}
@@ -34,7 +34,7 @@ public class AccountCleanupService {
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void markDeleted(List<Long> userIds) {
repository.addDeleted(userIds);
log.info("Account cleanup: marked users {} as deleted", userIds);
@@ -44,7 +44,7 @@ public class AccountCleanupService {
* The whole flagged set of Auth. Also lifts the tombstone of a user whose delete was undone in Auth.
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public void replaceDeleted(List<Long> userIds) {
repository.replaceDeleted(userIds);
log.info("Account cleanup: {} users marked as deleted", userIds.size());
@@ -54,7 +54,7 @@ public class AccountCleanupService {
* @param requestDryRun Auth's dry run; either side can ask for one
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public CleanupResult purge(List<Long> userIds, boolean requestDryRun) {
List<Long> tombstoned = repository.findDeleted(userIds);
if (tombstoned.size() < userIds.size()) {
@@ -69,7 +69,7 @@ public class AccountCleanupService {
* broken call can never wipe the library tables.
*/
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames"}, allEntries = true)
public CleanupResult prune(List<Long> knownUserIds, boolean requestDryRun) {
if (knownUserIds.isEmpty()) {
throw new IllegalArgumentException("knownUserIds must not be empty");
@@ -24,14 +24,14 @@ public class AccountStateService {
private final AccountStateRepository repository;
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void apply(List<AccountState> states) {
states.forEach(repository::apply);
log.info("Account state: {} users updated", states.size());
}
@Transactional
@CacheEvict(value = "publicStats", allEntries = true)
@CacheEvict(value = {"publicStats", "publicStatGames", "loginStates"}, allEntries = true)
public void replaceAll(List<AccountState> states) {
repository.replaceAll(states);
log.info("Account state: {} users replaced", states.size());
@@ -0,0 +1,23 @@
package core_service.modules.account.service;
import core_service.modules.account.repo.AccountStateRepository;
import core_service.shared.security.LoginState;
import core_service.shared.security.LoginStateCheck;
import core_service.shared.security.Public;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
@Service
@Public
@RequiredArgsConstructor
public class LoginStateService implements LoginStateCheck {
private final AccountStateRepository repository;
@Override
@Cacheable(value = "loginStates", key = "#userId")
public LoginState state(long userId) {
return repository.findLoginState(userId);
}
}
@@ -0,0 +1,86 @@
package core_service.modules.follow.api;
import core_service.modules.follow.api.dto.FollowsDto;
import core_service.modules.follow.api.dto.PlayersDto;
import core_service.modules.follow.domain.FollowEntry;
import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.service.FollowService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import jakarta.validation.constraints.Size;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/follows")
@RequiredArgsConstructor
public class FollowController {
private final FollowService followService;
@GetMapping
public ResponseEntity<FollowsDto> follows(@AuthenticationPrincipal UserPrincipal principal) {
Follows follows = followService.follows(principal.getIdAsLong());
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
}
/**
* The caller's own name is a normal hit.
*/
@GetMapping("/players")
public ResponseEntity<?> players(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam("q") @Size(max = 100) String q) {
PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q);
if (search.limited()) {
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
.body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches"));
}
return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList()));
}
@PutMapping("/by-name/{displayName}")
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) {
FollowResult result = followService.follow(principal.getIdAsLong(), displayName);
return switch (result) {
case FOLLOWED -> ResponseEntity.noContent().build();
case NOT_FOUND -> ResponseEntity.status(404)
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name"));
case SELF -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_SELF", "You cannot follow yourself"));
case LIMIT -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_LIMIT", "You follow too many people"));
};
}
@DeleteMapping("/by-name/{displayName}")
public ResponseEntity<Void> unfollow(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("displayName") @Size(max = 100) String displayName) {
followService.unfollow(principal.getIdAsLong(), displayName);
return ResponseEntity.noContent().build();
}
/**
* Ends a follow of the caller, or removes a follower of the caller; a row of someone else stays.
*/
@DeleteMapping("/{id}")
public ResponseEntity<Void> remove(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
followService.remove(principal.getIdAsLong(), id);
return ResponseEntity.noContent().build();
}
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList();
}
}
@@ -0,0 +1,18 @@
package core_service.modules.follow.api.dto;
import java.time.Instant;
import java.util.List;
/**
* Who the caller follows and who follows the caller. Names only: a person has no user id here.
*/
public record FollowsDto(List<Person> following, List<Person> followers) {
/**
* @param id the follow row, to end it; not a user id
* @param displayName null for a follower with a private profile
* @param lastActivityAt following only: when the newest feed message about this person arrived; null without one
*/
public record Person(long id, String displayName, Instant lastActivityAt) {
}
}
@@ -0,0 +1,15 @@
package core_service.modules.follow.api.dto;
import java.util.List;
/**
* Public profiles found by name: names that start with the query first, then by name.
*/
public record PlayersDto(List<Player> results) {
/**
* @param displayName the key of the profile URL
*/
public record Player(String displayName) {
}
}
@@ -0,0 +1,12 @@
package core_service.modules.follow.domain;
import java.time.Instant;
/**
* How far the followers of a user were told.
*
* @param syncStartedAt the last reported sync result, null when there was none
* @param wishlistChangeId the last reported WISHLIST row of sync_change
*/
public record Cursor(Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
}
@@ -0,0 +1,37 @@
package core_service.modules.follow.domain;
import java.util.Collection;
import java.util.List;
/**
* What a followed user did in one batch. The section names are those of the profile ticket; this module must not
* import the library's enum.
*/
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
static final String SUMMARY = "SUMMARY";
static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
public boolean isEmpty() {
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
}
public Digest plus(Digest other) {
return new Digest(newGames + other.newGames, achievements + other.achievements,
statusChanges + other.statusChanges, wishlistAdds + other.wishlistAdds);
}
/**
* Leaves out what the profile hides. The wishlist is never on the profile, so it has no section.
*
* @param sections the profile parts the user shows, null = all
*/
public Digest visibleOn(Collection<String> sections) {
if (sections == null) {
return this;
}
boolean summary = sections.contains(SUMMARY);
boolean status = STATUS_SECTIONS.stream().anyMatch(sections::contains);
return new Digest(summary ? newGames : 0, summary ? achievements : 0, status ? statusChanges : 0, wishlistAdds);
}
}
@@ -0,0 +1,28 @@
package core_service.modules.follow.domain;
/**
* One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum.
*/
public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) {
public static final String NEW_GAME = "NEW_GAME";
public static final String ACHIEVEMENT = "ACHIEVEMENT";
public static final String STATUS = "STATUS";
public static final String WISHLIST = "WISHLIST";
public static DigestItem newGame(long appid, String gameName) {
return new DigestItem(NEW_GAME, appid, gameName, null, null, null);
}
public static DigestItem achievement(long appid, String gameName, String itemName) {
return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null);
}
public static DigestItem status(long appid, String gameName, String from, String to) {
return new DigestItem(STATUS, appid, gameName, null, from, to);
}
public static DigestItem wishlist(long appid, String gameName) {
return new DigestItem(WISHLIST, appid, gameName, null, null, null);
}
}
@@ -0,0 +1,66 @@
package core_service.modules.follow.domain;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
/**
* The lines behind a {@link Digest}, in the order they are shown.
*/
public record DigestItems(List<DigestItem> items) {
public DigestItems plus(DigestItems other) {
List<DigestItem> all = new ArrayList<>(items);
all.addAll(other.items);
return new DigestItems(all);
}
/**
* Same rule as {@link Digest#visibleOn}.
*
* @param sections the profile parts the user shows, null = all
*/
public DigestItems visibleOn(Collection<String> sections) {
if (sections == null) {
return this;
}
boolean summary = sections.contains(Digest.SUMMARY);
boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains);
return new DigestItems(items.stream().filter(item -> switch (item.kind()) {
case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary;
case DigestItem.STATUS -> status;
default -> true;
}).toList());
}
/**
* One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps
* the place of the first move of its game.
*/
public DigestItems collapseStatus() {
Map<Long, String> lastTo = new HashMap<>();
for (DigestItem item : items) {
if (DigestItem.STATUS.equals(item.kind())) {
lastTo.put(item.appid(), item.toStatus());
}
}
List<DigestItem> result = new ArrayList<>();
Set<Long> seen = new HashSet<>();
for (DigestItem item : items) {
if (!DigestItem.STATUS.equals(item.kind())) {
result.add(item);
} else if (seen.add(item.appid())) {
String to = lastTo.get(item.appid());
if (!Objects.equals(item.fromStatus(), to)) {
result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to));
}
}
}
return new DigestItems(result);
}
}
@@ -0,0 +1,11 @@
package core_service.modules.follow.domain;
import java.time.Instant;
/**
* @param id the follow row, not a user id
* @param displayName null for a follower with a private profile
* @param lastActivityAt the newest digest note the caller got about this user; null for followers and without a note
*/
public record FollowEntry(long id, String displayName, Instant lastActivityAt) {
}
@@ -0,0 +1,8 @@
package core_service.modules.follow.domain;
public enum FollowResult {
FOLLOWED,
NOT_FOUND,
SELF,
LIMIT
}
@@ -0,0 +1,6 @@
package core_service.modules.follow.domain;
import java.util.List;
public record Follows(List<FollowEntry> following, List<FollowEntry> followers) {
}
@@ -0,0 +1,17 @@
package core_service.modules.follow.domain;
import java.util.List;
/**
* The answer of a player search: the names found, or "limited" when the caller searched too often.
*/
public record PlayerSearch(List<String> names, boolean limited) {
public static PlayerSearch tooMany() {
return new PlayerSearch(List.of(), true);
}
public static PlayerSearch of(List<String> names) {
return new PlayerSearch(names, false);
}
}
@@ -0,0 +1,286 @@
package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
import org.springframework.stereotype.Repository;
import java.sql.Array;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
/**
* The cursor per followed user, what happened since it, and the notes. Plain SQL: sync_change, sync_result,
* user_game and the trigger log belong to the library module, account tables to the account module.
*/
@Repository
@RequiredArgsConstructor
public class FollowDigestRepository {
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
private final NamedParameterJdbcTemplate jdbc;
/**
* Locks the row until the end of the transaction, so two digests of one user never report a batch twice.
*/
public Optional<Cursor> lockCursor(long userId) {
return jdbc.query("select sync_started_at, status_change_id, wishlist_change_id from follow_digest where user_id = :userId for update",
user(userId), (rs, i) -> {
Timestamp startedAt = rs.getTimestamp("sync_started_at");
return new Cursor(startedAt == null ? null : startedAt.toInstant(), rs.getLong("status_change_id"), rs.getLong("wishlist_change_id"));
}).stream().findFirst();
}
public Optional<Instant> findSyncStartedAt(long userId) {
return jdbc.query("select sync_started_at from sync_result where user_id = :userId", user(userId),
(rs, i) -> rs.getTimestamp("sync_started_at").toInstant()).stream().findFirst();
}
/**
* Hidden and removed games never count. Wishlist adds are counted apart, see {@link #countWishlist}.
*/
public Digest countSync(long userId) {
return jdbc.queryForObject("""
select
(select count(distinct c.appid) from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null) as new_games,
(select coalesce(sum(c.amount), 0) from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null) as achievements,
(select count(*) from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null and ug.removed_at is null) as status_changes""",
user(userId),
(rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0));
}
public long maxWishlistChangeId(long userId) {
Long max = jdbc.queryForObject(
"select coalesce(max(id), 0) from sync_change where user_id = :userId and kind = 'WISHLIST'",
user(userId), Long.class);
return max != null ? max : 0;
}
/**
* By row id, not by sync result: an add can join a result that was reported already. Wishlist games are no
* library games, so a hidden game does not matter.
*/
public int countWishlist(long userId, long afterId, long upToId) {
Integer count = jdbc.queryForObject("select count(*) from sync_change where user_id = :userId "
+ "and kind = 'WISHLIST' and id > :afterId and id <= :upToId",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId), Integer.class);
return count != null ? count : 0;
}
public long maxStatusChangeId(long userId) {
Long max = jdbc.queryForObject("select coalesce(max(id), 0) from user_game_status_change where user_id = :userId",
user(userId), Long.class);
return max != null ? max : 0;
}
/**
* The newest hand change after {@code afterId}, or {@code afterId} when there is none. A hand-only digest stops
* there, so the status rows of a sync stay for the digest of that sync.
*/
public long maxHandStatusChangeId(long userId, long afterId) {
Long max = jdbc.queryForObject("select max(id) from user_game_status_change "
+ "where user_id = :userId and by_user and id > :afterId",
user(userId).addValue("afterId", afterId), Long.class);
return max != null ? max : afterId;
}
/**
* The changes the user made by hand: no old status = a game added by hand, else a status change.
*/
public Digest countHand(long userId, long afterId, long upToId) {
return jdbc.queryForObject("""
select count(*) filter (where c.from_status is null) as new_games,
count(*) filter (where c.from_status is not null) as status_changes
from user_game_status_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId
and ug.hidden_at is null and ug.removed_at is null""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
}
/**
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
*/
public DigestItems findSyncItems(long userId) {
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
items.addAll(findSyncAchievements(userId));
return new DigestItems(items);
}
private List<DigestItem> findSyncNewGames(long userId) {
return jdbc.query("""
select distinct c.appid, sg.name from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
}
private List<DigestItem> findSyncAchievements(long userId) {
return jdbc.query("""
with gained as (
select c.appid, sum(c.amount) as amount from sync_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
group by c.appid),
unlocks as (
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
from user_game_achievement a
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
where a.user_id = :userId and a.unlocked
union all
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
from user_psn_trophy ut
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
where ut.user_id = :userId),
ranked as (
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
from unlocks u join gained g on g.appid = u.appid)
select r.appid, sg.name as game_name, r.name from ranked r
join gained g on g.appid = r.appid
left join steam_game sg on sg.appid = r.appid
where r.rn <= least(g.amount, :cap)
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
}
/**
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
*/
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
return new DigestItems(jdbc.query("""
select c.appid, sg.name, c.from_status, c.to_status
from user_game_status_change c
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
and (c.by_user or (:withSyncMoves and c.from_status is not null))
and ug.hidden_at is null and ug.removed_at is null
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
(rs, i) -> rs.getString("from_status") == null
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
rs.getString("to_status"))));
}
/**
* The lines behind {@link #countWishlist}, same id range.
*/
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
return new DigestItems(jdbc.query("""
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
left join steam_game sg on sg.appid = c.appid
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
order by c.id""",
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
}
/**
* @return null when there is no row or the user did not send sections: nothing is filtered
*/
public List<String> findPublicSections(long userId) {
return jdbc.query("select sections from public_user where user_id = :userId", user(userId), rs -> {
if (!rs.next()) {
return null;
}
Array sections = rs.getArray("sections");
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
});
}
/**
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
* not deleted. A name is stored as it is now.
*
* @return the ids of the notes written
*/
public List<Long> insertDigestNotes(long userId, Digest digest) {
return jdbc.query("""
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
status_changes, wishlist_adds)
select f.user_id, 'DIGEST', f.followed_user_id,
(select display_name from account_name where user_id = f.followed_user_id),
:newGames, :achievements, :statusChanges, :wishlistAdds
from follow f
where f.followed_user_id = :userId
and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))
returning id""",
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()),
(rs, i) -> rs.getLong("id"));
}
/**
* One copy of the lines per note: the note owns them and they go with it.
*/
public void insertDigestItems(List<Long> noteIds, DigestItems items) {
if (noteIds.isEmpty() || items.items().isEmpty()) {
return;
}
List<SqlParameterSource> rows = new ArrayList<>();
for (long noteId : noteIds) {
for (DigestItem item : items.items()) {
rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind())
.addValue("appid", item.appid()).addValue("gameName", item.gameName())
.addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus())
.addValue("toStatus", item.toStatus()));
}
}
jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) "
+ "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)",
rows.toArray(new SqlParameterSource[0]));
}
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
jdbc.update("update follow_digest set sync_started_at = :syncStartedAt, status_change_id = :statusChangeId, "
+ "wishlist_change_id = :wishlistChangeId "
+ "where user_id = :userId",
user(userId).addValue("syncStartedAt", syncStartedAt == null ? null : Timestamp.from(syncStartedAt))
.addValue("statusChangeId", statusChangeId).addValue("wishlistChangeId", wishlistChangeId));
}
/**
* Followed users with a pending hand change whose newest pending change is older than {@code quietBefore}.
*/
public List<Long> findQuietHandChanges(Instant quietBefore) {
return jdbc.queryForList("""
select d.user_id from follow_digest d
where exists (select 1 from follow f where f.followed_user_id = d.user_id)
and exists (select 1 from user_game_status_change c
where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id)
and not exists (select 1 from user_game_status_change c
where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id
and c.changed_at > :quietBefore)""",
new MapSqlParameterSource("quietBefore", Timestamp.from(quietBefore)), Long.class);
}
private static MapSqlParameterSource user(long userId) {
return new MapSqlParameterSource("userId", userId);
}
}
@@ -0,0 +1,146 @@
package core_service.modules.follow.repo;
import core_service.modules.follow.domain.FollowEntry;
import core_service.shared.sql.LikePattern;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
import org.springframework.stereotype.Repository;
import java.sql.Timestamp;
import java.util.List;
import java.util.Optional;
/**
* Follow rows. Plain SQL: the tables of other modules (account_name, public_user, blocked_user, deleted_user) are
* read, not mapped.
*/
@Repository
@RequiredArgsConstructor
public class FollowRepository {
static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
Timestamp lastActivityAt = rs.getTimestamp("last_activity_at");
return new FollowEntry(rs.getLong("id"), rs.getString("display_name"),
lastActivityAt == null ? null : lastActivityAt.toInstant());
};
private final NamedParameterJdbcTemplate jdbc;
/**
* The user behind a name, when the profile is public and the user is neither blocked nor deleted.
*
* @return empty when no user or more than one user has the name
*/
public Optional<Long> findFollowable(String displayName) {
List<Long> ids = jdbc.queryForList("""
select a.user_id from account_name a
join public_user p on p.user_id = a.user_id
where lower(a.display_name) = lower(:name)
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
limit 2""", new MapSqlParameterSource("name", displayName), Long.class);
return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
}
/**
* Public names that contain the query, case-insensitive; names that start with it come first. Same visibility
* rules as {@link #findFollowable}. Wildcards in the query match themselves.
*/
public List<String> searchPublicNames(String query, int limit) {
String escaped = LikePattern.escapeLower(query);
return jdbc.queryForList("""
select a.display_name from account_name a
join public_user p on p.user_id = a.user_id
where lower(a.display_name) like :pattern escape '\\'
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name)
limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%")
.addValue("prefix", escaped + "%").addValue("limit", limit), String.class);
}
/**
* Checks the visibility again in the same statement, so a user who went private in between gets no follower.
*
* @return true when a row was written
*/
public boolean insert(long userId, long followedUserId) {
return jdbc.update("""
insert into follow (user_id, followed_user_id)
select :userId, :followedUserId
where exists (select 1 from public_user where user_id = :followedUserId)
on conflict do nothing""", pair(userId, followedUserId)) > 0;
}
public int countFollowing(long userId) {
Integer count = jdbc.queryForObject("select count(*) from follow where user_id = :userId",
new MapSqlParameterSource("userId", userId), Integer.class);
return count != null ? count : 0;
}
/**
* With the first follower the cursor starts at "now", so the follower gets no old activity.
*/
public void startDigestIfFirst(long followedUserId) {
jdbc.update("""
insert into follow_digest (user_id, sync_started_at, status_change_id, wishlist_change_id)
select :id, (select sync_started_at from sync_result where user_id = :id),
coalesce((select max(id) from user_game_status_change where user_id = :id), 0),
coalesce((select max(id) from sync_change where user_id = :id and kind = 'WISHLIST'), 0)
where (select count(*) from follow where followed_user_id = :id) = 1
on conflict (user_id) do update
set sync_started_at = excluded.sync_started_at, status_change_id = excluded.status_change_id,
wishlist_change_id = excluded.wishlist_change_id""",
new MapSqlParameterSource("id", followedUserId));
}
public void deleteByFollowed(long userId, long followedUserId) {
jdbc.update("delete from follow where user_id = :userId and followed_user_id = :followedUserId",
pair(userId, followedUserId));
}
/**
* Only a row of the caller: the caller follows, or is followed.
*/
public void delete(long userId, long followId) {
jdbc.update("delete from follow where id = :id and (user_id = :userId or followed_user_id = :userId)",
new MapSqlParameterSource("id", followId).addValue("userId", userId));
}
public List<FollowEntry> findFollowing(long userId) {
return jdbc.query("""
select f.id, a.display_name,
(select max(n.created_at) from inbox_note n
where n.user_id = f.user_id and n.subject_user_id = f.followed_user_id
and n.kind = 'DIGEST') as last_activity_at
from follow f
left join account_name a on a.user_id = f.followed_user_id
where f.user_id = :userId
order by lower(a.display_name), f.id
limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
}
/**
* The name of a follower with a private profile is left out.
*/
public List<FollowEntry> findFollowers(long userId) {
return jdbc.query("""
select f.id, case when p.user_id is not null then a.display_name end as display_name,
null::timestamptz as last_activity_at
from follow f
left join public_user p on p.user_id = f.user_id
left join account_name a on a.user_id = f.user_id
where f.followed_user_id = :userId
order by lower(case when p.user_id is not null then a.display_name end) nulls last, f.id
limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
}
private static MapSqlParameterSource pair(long userId, long followedUserId) {
return new MapSqlParameterSource("userId", userId).addValue("followedUserId", followedUserId);
}
}
@@ -0,0 +1,101 @@
package core_service.modules.follow.service;
import core_service.modules.follow.domain.Cursor;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItems;
import core_service.modules.follow.repo.FollowDigestRepository;
import core_service.shared.event.LibraryActivityEvent;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
/**
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
* last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets
* the lines behind its counts (status moves collapsed per game).
*/
@Service
@Slf4j
public class FollowDigestService {
private final FollowDigestRepository digestRepository;
private final TransactionTemplate transaction;
private final Duration quiet;
public FollowDigestService(FollowDigestRepository digestRepository, PlatformTransactionManager transactionManager,
@Value("${app.follow.quiet-minutes:10}") long quietMinutes) {
this.digestRepository = digestRepository;
this.transaction = new TransactionTemplate(transactionManager);
this.quiet = Duration.ofMinutes(quietMinutes);
}
@EventListener
public void onLibraryActivity(LibraryActivityEvent event) {
digestOrLog(event.userId(), true);
}
/**
* Only hand changes: it never takes a sync result, which may still wait for its achievements.
*/
@Scheduled(initialDelayString = "${app.follow.digest-ms:600000}", fixedDelayString = "${app.follow.digest-ms:600000}")
public void digestHandChanges() {
List<Long> userIds = digestRepository.findQuietHandChanges(Instant.now().minus(quiet));
for (long userId : userIds) {
digestOrLog(userId, false);
}
}
private void digestOrLog(long userId, boolean withSync) {
try {
digest(userId, withSync);
} catch (RuntimeException e) {
log.warn("Follower digest of user {} failed", userId, e);
}
}
void digest(long userId, boolean withSync) {
transaction.executeWithoutResult(tx -> {
Optional<Cursor> locked = digestRepository.lockCursor(userId);
if (locked.isEmpty()) {
return;
}
Cursor cursor = locked.get();
long upTo = withSync ? digestRepository.maxStatusChangeId(userId)
: digestRepository.maxHandStatusChangeId(userId, cursor.statusChangeId());
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
Instant syncStartedAt = cursor.syncStartedAt();
long wishlistUpTo = cursor.wishlistChangeId();
Optional<Instant> current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty();
boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt));
DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync);
if (withSync) {
if (newSync) {
digest = digest.plus(digestRepository.countSync(userId));
items = items.plus(digestRepository.findSyncItems(userId));
syncStartedAt = current.get();
}
wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
digest = digest.plus(new Digest(0, 0, 0,
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo));
}
List<String> sections = digestRepository.findPublicSections(userId);
digest = digest.visibleOn(sections);
items = items.visibleOn(sections).collapseStatus();
if (!digest.isEmpty()) {
List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
digestRepository.insertDigestItems(noteIds, items);
}
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
});
}
}
@@ -0,0 +1,80 @@
package core_service.modules.follow.service;
import core_service.modules.follow.domain.FollowResult;
import core_service.modules.follow.domain.Follows;
import core_service.modules.follow.domain.PlayerSearch;
import core_service.modules.follow.repo.FollowRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@Transactional
@RequiredArgsConstructor
public class FollowService {
static final int MAX_FOLLOWING = 500;
static final int MIN_QUERY_LENGTH = 2;
private final FollowRepository followRepository;
private final PlayerSearchLimiter limiter;
/**
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
*/
@RequiresRole(Role.USER)
public FollowResult follow(long userId, String displayName) {
Optional<Long> followed = followRepository.findFollowable(displayName);
if (followed.isEmpty()) {
return FollowResult.NOT_FOUND;
}
if (followed.get() == userId) {
return FollowResult.SELF;
}
if (followRepository.countFollowing(userId) >= MAX_FOLLOWING) {
return FollowResult.LIMIT;
}
if (followRepository.insert(userId, followed.get())) {
followRepository.startDigestIfFirst(followed.get());
}
return FollowResult.FOLLOWED;
}
@RequiresRole(Role.USER)
public void unfollow(long userId, String displayName) {
followRepository.findFollowable(displayName)
.ifPresent(followed -> followRepository.deleteByFollowed(userId, followed));
}
@RequiresRole(Role.USER)
public void remove(long userId, long followId) {
followRepository.delete(userId, followId);
}
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public Follows follows(long userId) {
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
}
/**
* A query shorter than two characters finds nothing and does not count against the limit.
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public PlayerSearch searchPlayers(long userId, String query) {
String stripped = query.strip();
if (stripped.length() < MIN_QUERY_LENGTH) {
return PlayerSearch.of(List.of());
}
if (!limiter.allow(userId)) {
return PlayerSearch.tooMany();
}
return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT));
}
}
@@ -0,0 +1,32 @@
package core_service.modules.follow.service;
import io.github.resilience4j.ratelimiter.RateLimiter;
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.util.concurrent.ConcurrentHashMap;
/**
* Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by
* the number of users. The limit holds per instance.
*/
@Component
public class PlayerSearchLimiter {
private final RateLimiterConfig config;
private final ConcurrentHashMap<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) {
this.config = RateLimiterConfig.custom()
.limitForPeriod(perMinute)
.limitRefreshPeriod(Duration.ofMinutes(1))
.timeoutDuration(Duration.ZERO)
.build();
}
public boolean allow(long userId) {
return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission();
}
}
@@ -52,6 +52,23 @@ public final class IgdbResponses {
@JsonProperty("game_modes") java.util.List<Named> gameModes
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record IdNamed(@JsonProperty("id") Long id, @JsonProperty("name") String name) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record InvolvedCompany(
@JsonProperty("company") IdNamed company,
@JsonProperty("developer") Boolean developer,
@JsonProperty("publisher") Boolean publisher
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Credits(
@JsonProperty("id") Long id,
@JsonProperty("collections") java.util.List<IdNamed> collections,
@JsonProperty("involved_companies") java.util.List<InvolvedCompany> involvedCompanies
) {}
@JsonIgnoreProperties(ignoreUnknown = true)
public record Edition(
@JsonProperty("id") Long id,
@@ -12,8 +12,10 @@ import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.RestClient;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneOffset;
import java.util.ArrayList;
import java.util.Arrays;
@@ -21,12 +23,14 @@ import java.util.Comparator;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.TreeSet;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@@ -48,6 +52,17 @@ public class IgdbApiService {
*/
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
public record Series(long id, String name) {}
public record Company(long id, String name, boolean developer, boolean publisher) {}
/**
* @param series IGDB collections, e.g. "Mass Effect"
* @param companies only those that developed or published, one entry per company; both lists are empty when IGDB
* has none
*/
public record Credits(List<Series> series, List<Company> companies) {}
/**
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
*/
@@ -63,6 +78,9 @@ public class IgdbApiService {
*/
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
/** @param year null when IGDB knows no release date */
public record NameAndYear(String name, Integer year) {}
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
private static final int SEARCH_FETCH_LIMIT = 50;
private static final int SEARCH_LIMIT = 20;
@@ -155,6 +173,65 @@ public class IgdbApiService {
}
}
/**
* @return the series and companies of the Steam apps IGDB links to a game, empty lists when that game has none;
* apps IGDB does not link are missing. An app linked to several games gets the credits of all of them. Empty when
* the adapter is off or a request failed.
*/
public Optional<Map<Long, Credits>> getCredits(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, Credits> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
creditsOf(steamByIgdb.keySet()).forEach((igdbId, credits) ->
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, credits, IgdbApiService::union)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
/**
* Like {@link #getCredits}, but for IGDB game ids.
*/
public Optional<Map<Long, Credits>> getCreditsByGameIds(Collection<Long> igdbIds) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Credits> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(creditsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for credits", e);
return Optional.empty();
}
}
private static Credits union(Credits a, Credits b) {
Map<Long, Series> series = new LinkedHashMap<>();
Stream.concat(a.series().stream(), b.series().stream()).forEach(s -> series.putIfAbsent(s.id(), s));
return new Credits(List.copyOf(series.values()),
byCompany(Stream.concat(a.companies().stream(), b.companies().stream())));
}
// One entry per company, its roles OR-ed.
private static List<Company> byCompany(Stream<Company> companies) {
Map<Long, Company> byId = new LinkedHashMap<>();
companies.forEach(c -> byId.merge(c.id(), c, (x, y) ->
new Company(x.id(), x.name(), x.developer() || y.developer(), x.publisher() || y.publisher())));
return List.copyOf(byId.values());
}
private static GameTags union(GameTags a, GameTags b) {
return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes()));
}
@@ -251,8 +328,8 @@ public class IgdbApiService {
Map<Long, Set<Integer>> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
yearsOf(steamByIgdb.keySet()).forEach((igdbId, year) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(year)));
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(date.getYear())));
}
return Optional.of(result);
} catch (Exception e) {
@@ -272,8 +349,8 @@ public class IgdbApiService {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, Set<Integer>> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
yearsOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
.forEach((igdbId, year) -> result.put(igdbId, Set.of(year)));
datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
.forEach((igdbId, date) -> result.put(igdbId, Set.of(date.getYear())));
}
return Optional.of(result);
} catch (Exception e) {
@@ -282,6 +359,62 @@ public class IgdbApiService {
}
}
/**
* The first release date per Steam appid (UTC); the earliest when an appid belongs to several IGDB games. An app
* IGDB has no date for is missing. Empty when the adapter is off or IGDB failed.
*/
public Optional<Map<Long, LocalDate>> getReleaseDates(Collection<Long> steamAppids) {
if (!isEnabled() || steamAppids.isEmpty()) {
return Optional.empty();
}
try {
List<Long> appids = List.copyOf(steamAppids);
Map<Long, LocalDate> result = new HashMap<>();
for (int from = 0; from < appids.size(); from += BATCH) {
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
.forEach(appid -> result.merge(appid, date, (a, b) -> a.isBefore(b) ? a : b)));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for release dates", e);
return Optional.empty();
}
}
/**
* Like {@link #getReleaseDates}, but for IGDB game ids.
*/
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
return inBatches(igdbIds, "release dates", this::datesOf);
}
/**
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
* when the adapter is off, nothing was asked or a request failed.
*/
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
}
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
Function<List<Long>, Map<Long, T>> batchQuery) {
if (!isEnabled() || igdbIds.isEmpty()) {
return Optional.empty();
}
try {
List<Long> ids = List.copyOf(igdbIds);
Map<Long, T> result = new HashMap<>();
for (int from = 0; from < ids.size(); from += BATCH) {
result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
}
return Optional.of(result);
} catch (Exception e) {
log.error("Error communicating with IGDB for {}", what, e);
return Optional.empty();
}
}
/**
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
*/
@@ -426,6 +559,44 @@ public class IgdbApiService {
return result;
}
private Map<Long, Credits> creditsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.Credits[] games = query("/games",
"fields collections.name,involved_companies.company.name,involved_companies.developer,"
+ "involved_companies.publisher; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Credits[].class);
Map<Long, Credits> result = new HashMap<>();
for (IgdbResponses.Credits game : games) {
if (game.id() != null && igdbIds.contains(game.id())) {
result.put(game.id(), new Credits(seriesOf(game.collections()), companiesOf(game.involvedCompanies())));
}
}
return result;
}
private static List<Series> seriesOf(List<IgdbResponses.IdNamed> collections) {
return collections == null ? List.of() : collections.stream()
.filter(IgdbApiService::hasName)
.map(c -> new Series(c.id(), c.name().trim()))
.toList();
}
// Only developers and publishers; porting or supporting companies are dropped.
private static List<Company> companiesOf(List<IgdbResponses.InvolvedCompany> involved) {
return involved == null ? List.of() : byCompany(involved.stream()
.filter(i -> hasName(i.company()))
.map(i -> new Company(i.company().id(), i.company().name().trim(),
Boolean.TRUE.equals(i.developer()), Boolean.TRUE.equals(i.publisher())))
.filter(c -> c.developer() || c.publisher()));
}
private static boolean hasName(IgdbResponses.IdNamed named) {
return named != null && named.id() != null && named.name() != null && !named.name().isBlank();
}
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
@@ -443,25 +614,43 @@ public class IgdbApiService {
return result;
}
private Map<Long, Integer> yearsOf(Collection<Long> igdbIds) {
private Map<Long, LocalDate> datesOf(Collection<Long> igdbIds) {
if (igdbIds.isEmpty()) {
return Map.of();
}
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
IgdbResponses.Game[] games = query("/games",
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
Map<Long, Integer> result = new HashMap<>();
IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
Map<Long, LocalDate> result = new HashMap<>();
for (IgdbResponses.Game game : games) {
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
result.put(game.id(), year(game.firstReleaseDate()));
result.put(game.id(), date(game.firstReleaseDate()));
}
}
return result;
}
private Map<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
Map<Long, NameAndYear> result = new HashMap<>();
for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) {
String name = trimmedName(game);
if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) {
result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate())));
}
}
return result;
}
private IgdbResponses.Game[] gamesById(Collection<Long> igdbIds, String fields) {
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";",
IgdbResponses.Game[].class);
}
private static int year(long unixSeconds) {
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).getYear();
return date(unixSeconds).getYear();
}
private static LocalDate date(long unixSeconds) {
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).toLocalDate();
}
private static List<String> names(List<IgdbResponses.Named> named) {
@@ -516,7 +705,8 @@ public class IgdbApiService {
.uri(path)
.header("Client-ID", clientId)
.header("Authorization", "Bearer " + token)
.contentType(MediaType.TEXT_PLAIN)
// Without the charset the body goes out in ISO-8859-1 and a name like "Yōtei" arrives as "Y?tei".
.contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8))
.accept(MediaType.APPLICATION_JSON)
.body(body)
.retrieve()
@@ -0,0 +1,51 @@
package core_service.modules.inbox.api;
import core_service.modules.inbox.api.dto.InboxDto;
import core_service.modules.inbox.api.dto.NoteDetailsDto;
import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.service.InboxService;
import core_service.shared.api.ErrorResponse;
import core_service.shared.security.UserPrincipal;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/inbox")
@RequiredArgsConstructor
public class InboxController {
private final InboxService inboxService;
@GetMapping
public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) {
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.profileName(), note.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
.toList(), inbox.unread()));
}
@GetMapping("/{id}/details")
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
return inboxService.details(principal.getIdAsLong(), id)
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
.map(detail -> new NoteDetailsDto.Detail(detail.appid(), detail.kind().name(), detail.gameName(),
detail.itemName(), detail.fromStatus(), detail.toStatus()))
.toList())))
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("NOTE_NOT_FOUND", "No such note")));
}
@PutMapping("/read")
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
inboxService.markAllRead(principal.getIdAsLong());
return ResponseEntity.noContent().build();
}
}
@@ -0,0 +1,20 @@
package core_service.modules.inbox.api.dto;
import java.time.Instant;
import java.util.List;
/**
* @param unread all unread notes, also those beyond the newest 50 in {@code results}
*/
public record InboxDto(List<Note> results, int unread) {
/**
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
* @param displayName the name of the followed user when the note was written; never a user id
* @param profileName the subject's current name while the profile is public, else null; never an id
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record Note(long id, String kind, String displayName, String profileName, int newGames, int achievements, int statusChanges,
int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
}
@@ -0,0 +1,16 @@
package core_service.modules.inbox.api.dto;
import java.util.List;
public record NoteDetailsDto(List<Detail> results) {
/**
* @param appid the game key as stored in the note; may be a merged key
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
* @param itemName ACHIEVEMENT only: the achievement or trophy name
* @param fromStatus STATUS only
* @param toStatus STATUS only
*/
public record Detail(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
}
@@ -0,0 +1,9 @@
package core_service.modules.inbox.domain;
import java.util.List;
/**
* @param unread all unread notes, also those beyond the list
*/
public record Inbox(List<InboxNote> notes, int unread) {
}
Loaded 100 of 428 files, more files were not shown because too many files have changed in this diff. Show more