Compare commits

...
12 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
117 changed files with 4086 additions and 235 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,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,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>
+101 -50
View File
@@ -1,22 +1,27 @@
# 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,
@@ -40,12 +45,35 @@ To Be Played is split into three repositories:
`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.
```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) 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`).
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. The nightly job at 03:15 deletes notes older than 90 days (`app.inbox.keep-days`); the list shows the newest 50.
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
@@ -57,30 +85,22 @@ To Be Played is split into three repositories:
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).
```mermaid
flowchart LR
A["anchor: random of top 5 played"] --> S[similar]
S --> M["PickMix: rotate 4 sources, dedupe, random fallback"]
L[longest waiting] --> M
R[random] --> M
N["next up: random of top 5 in order"] --> M
M --> O["games + reason"]
```
- **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. Migration V47 asks
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
@@ -172,8 +192,8 @@ flowchart LR
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
@@ -198,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
@@ -231,11 +251,14 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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` (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), `/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 |
@@ -252,6 +275,12 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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 |
@@ -261,7 +290,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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; slice 3: `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 |
| `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/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 |
@@ -272,14 +301,17 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `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}], followers: [{id, displayName}]}`, `id` is the follow row; a follower with a private profile has `displayName` null) | login |
| `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/inbox` (`{results: [{id, kind, displayName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | 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, 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 |
@@ -292,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
@@ -335,8 +367,8 @@ flowchart LR
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, public_user, sync_change, user_game_status_change, account_name"| Db
Inbox --> 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, listed_user, public_user, follow, account_name, user_seen_at"| Db
```
@@ -347,7 +379,7 @@ flowchart LR
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `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`: the notes of a user, `GET /api/inbox`, the 90-day purge |
| `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 |
@@ -389,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
@@ -455,18 +489,20 @@ At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/l
### 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
@@ -474,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
@@ -750,7 +788,9 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
- 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. A game the user removed stays
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).
@@ -804,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:
@@ -845,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:
@@ -859,7 +900,9 @@ Configuration comes from environment variables:
| `STEAM_API_KEY` | Steam Web API key |
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
| `DB_PASSWORD` | Local PostgreSQL password |
| `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
@@ -868,6 +911,14 @@ duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no ha
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
```bash
@@ -1,14 +1,17 @@
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;
@@ -16,6 +19,7 @@ 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;
@@ -33,6 +37,20 @@ public class FollowController {
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) {
@@ -63,6 +81,6 @@ public class FollowController {
}
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName())).toList();
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.follow.api.dto;
import java.time.Instant;
import java.util.List;
/**
@@ -10,7 +11,8 @@ 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) {
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) {
}
}
@@ -9,8 +9,8 @@ import java.util.List;
*/
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
private static final String SUMMARY = "SUMMARY";
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
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;
@@ -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);
}
}
@@ -1,8 +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) {
public record FollowEntry(long id, String displayName, Instant lastActivityAt) {
}
@@ -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);
}
}
@@ -2,15 +2,19 @@ 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;
@@ -22,6 +26,8 @@ import java.util.Optional;
@RequiredArgsConstructor
public class FollowDigestRepository {
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
private final NamedParameterJdbcTemplate jdbc;
/**
@@ -83,6 +89,17 @@ public class FollowDigestRepository {
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.
*/
@@ -98,6 +115,90 @@ public class FollowDigestRepository {
(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
*/
@@ -115,10 +216,10 @@ public class FollowDigestRepository {
* 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 how many notes were written
* @return the ids of the notes written
*/
public int insertDigestNotes(long userId, Digest digest) {
return jdbc.update("""
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,
@@ -128,9 +229,32 @@ public class FollowDigestRepository {
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))""",
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()));
.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) {
@@ -1,12 +1,14 @@
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;
@@ -19,9 +21,13 @@ import java.util.Optional;
public class FollowRepository {
static final int LIST_LIMIT = 500;
public static final int SEARCH_LIMIT = 20;
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> new FollowEntry(rs.getLong("id"),
rs.getString("display_name"));
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;
@@ -41,6 +47,23 @@ public class FollowRepository {
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.
*
@@ -91,7 +114,11 @@ public class FollowRepository {
public List<FollowEntry> findFollowing(long userId) {
return jdbc.query("""
select f.id, a.display_name from follow f
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
@@ -103,7 +130,8 @@ public class FollowRepository {
*/
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
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
@@ -2,6 +2,7 @@ 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;
@@ -19,7 +20,8 @@ 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.
* 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
@@ -67,23 +69,31 @@ public class FollowDigestService {
return;
}
Cursor cursor = locked.get();
long upTo = digestRepository.maxStatusChangeId(userId);
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) {
Optional<Instant> current = digestRepository.findSyncStartedAt(userId);
if (current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt))) {
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));
}
digest = digest.visibleOn(digestRepository.findPublicSections(userId));
List<String> sections = digestRepository.findPublicSections(userId);
digest = digest.visibleOn(sections);
items = items.visibleOn(sections).collapseStatus();
if (!digest.isEmpty()) {
digestRepository.insertDigestNotes(userId, digest);
List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
digestRepository.insertDigestItems(noteIds, items);
}
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
});
@@ -2,6 +2,7 @@ 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;
@@ -9,6 +10,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Optional;
@Service
@@ -17,8 +19,10 @@ import java.util.Optional;
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.
@@ -57,4 +61,20 @@ public class FollowService {
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();
}
}
@@ -1,13 +1,17 @@
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;
@@ -23,11 +27,22 @@ public class InboxController {
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.newGames(),
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read()))
.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());
@@ -11,8 +11,10 @@ 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, int newGames, int achievements, int statusChanges,
int wishlistAdds, Instant createdAt, boolean read) {
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) {
}
}
@@ -4,7 +4,9 @@ import java.time.Instant;
/**
* @param subjectName the name of the followed user when the note was written
* @param profileName the current name of the subject while the profile is public, else null
* @param details the note can be opened: it has detail rows and the subject is still followed and public
*/
public record InboxNote(long id, NoteKind kind, String subjectName, int newGames, int achievements,
int statusChanges, int wishlistAdds, Instant createdAt, boolean read) {
public record InboxNote(long id, NoteKind kind, String subjectName, String profileName, int newGames, int achievements,
int statusChanges, int wishlistAdds, Instant createdAt, boolean read, boolean details) {
}
@@ -0,0 +1,7 @@
package core_service.modules.inbox.domain;
/**
* One line behind a digest note. Names are snapshots from when the note was written.
*/
public record NoteDetail(long appid, NoteDetailKind kind, String gameName, String itemName, String fromStatus, String toStatus) {
}
@@ -0,0 +1,29 @@
package core_service.modules.inbox.domain;
import java.util.Collection;
import java.util.List;
public enum NoteDetailKind {
NEW_GAME,
ACHIEVEMENT,
STATUS,
WISHLIST;
private static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
/**
* Mirror of follow.domain.Digest.visibleOn, modules may not import each other.
*
* @param sections the profile parts the user shows, null = all
*/
public boolean visibleOn(Collection<String> sections) {
if (sections == null) {
return true;
}
return switch (this) {
case NEW_GAME, ACHIEVEMENT -> sections.contains("SUMMARY");
case STATUS -> STATUS_SECTIONS.stream().anyMatch(sections::contains);
case WISHLIST -> true;
};
}
}
@@ -1,15 +1,20 @@
package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.InboxNote;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.domain.NoteDetailKind;
import core_service.modules.inbox.domain.NoteKind;
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.Array;
import java.sql.Timestamp;
import java.time.Instant;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
@Repository
@RequiredArgsConstructor
@@ -19,14 +24,67 @@ public class InboxRepository {
public List<InboxNote> findNewest(long userId, int limit) {
return jdbc.query("""
select id, kind, subject_name, new_games, achievements, status_changes, wishlist_adds, created_at, read_at
from inbox_note where user_id = :userId
order by created_at desc, id desc
select n.id, n.kind, n.subject_name, n.new_games, n.achievements, n.status_changes, n.wishlist_adds,
n.created_at, n.read_at,
(select a.display_name from account_name a join public_user p on p.user_id = a.user_id
where a.user_id = n.subject_user_id
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)) as profile_name,
exists (select 1 from inbox_note_detail d where d.note_id = n.id)
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id) as details
from inbox_note n where n.user_id = :userId
order by n.created_at desc, n.id desc
limit :limit""", new MapSqlParameterSource("userId", userId).addValue("limit", limit),
(rs, i) -> new InboxNote(rs.getLong("id"), NoteKind.valueOf(rs.getString("kind")),
rs.getString("subject_name"), rs.getInt("new_games"), rs.getInt("achievements"),
rs.getString("subject_name"), rs.getString("profile_name"), rs.getInt("new_games"), rs.getInt("achievements"),
rs.getInt("status_changes"), rs.getInt("wishlist_adds"),
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null));
rs.getTimestamp("created_at").toInstant(), rs.getTimestamp("read_at") != null,
rs.getBoolean("details")));
}
/**
* Ownership and the follow re-check in one statement.
*
* @return the followed user of a DIGEST note of this user, empty when the note is not theirs, they no longer
* follow the subject or the subject is no longer public
*/
public Optional<Long> findFollowedSubject(long userId, long noteId) {
return jdbc.query("""
select n.subject_user_id from inbox_note n
where n.id = :noteId and n.user_id = :userId and n.kind = 'DIGEST'
and exists (select 1 from follow f where f.user_id = n.user_id and f.followed_user_id = n.subject_user_id)
and exists (select 1 from public_user p where p.user_id = n.subject_user_id)""",
new MapSqlParameterSource("userId", userId).addValue("noteId", noteId),
(rs, i) -> rs.getLong("subject_user_id")).stream().findFirst();
}
/**
* @return null when there is no row or the user did not send sections: nothing is filtered
*/
public List<String> findPublicSections(long subjectUserId) {
return jdbc.query("select sections from public_user where user_id = :userId",
new MapSqlParameterSource("userId", subjectUserId), rs -> {
if (!rs.next()) {
return null;
}
Array sections = rs.getArray("sections");
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
});
}
/**
* Without the games the subject hides now.
*/
public List<NoteDetail> findDetails(long noteId, long subjectUserId) {
return jdbc.query("""
select d.appid, d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d
where d.note_id = :noteId
and not exists (select 1 from user_game ug
where ug.user_id = :subjectUserId and ug.appid = d.appid and ug.hidden_at is not null)
order by d.id""", new MapSqlParameterSource("noteId", noteId).addValue("subjectUserId", subjectUserId),
(rs, i) -> new NoteDetail(rs.getLong("appid"), NoteDetailKind.valueOf(rs.getString("kind")), rs.getString("game_name"),
rs.getString("item_name"), rs.getString("from_status"), rs.getString("to_status")));
}
public int countUnread(long userId) {
@@ -1,6 +1,7 @@
package core_service.modules.inbox.service;
import core_service.modules.inbox.domain.Inbox;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.modules.inbox.repo.InboxRepository;
import core_service.shared.security.RequiresRole;
import core_service.shared.security.Role;
@@ -13,6 +14,8 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
@Service
@Slf4j
@@ -32,6 +35,22 @@ public class InboxService {
return new Inbox(inboxRepository.findNewest(userId, LIST_SIZE), inboxRepository.countUnread(userId));
}
/**
* Checked again on every read: the caller must own the note, still follow the subject, the subject must be
* public and show the part.
*
* @return empty when the note cannot be opened
*/
@RequiresRole(Role.USER)
@Transactional(readOnly = true)
public Optional<List<NoteDetail>> details(long userId, long noteId) {
return inboxRepository.findFollowedSubject(userId, noteId).map(subject -> {
List<String> sections = inboxRepository.findPublicSections(subject);
return inboxRepository.findDetails(noteId, subject).stream()
.filter(detail -> detail.kind().visibleOn(sections)).toList();
});
}
@RequiresRole(Role.USER)
@Transactional
public void markAllRead(long userId) {
@@ -10,10 +10,12 @@ import core_service.modules.library.api.dto.AuditEntryDto;
import core_service.modules.library.api.dto.BrokenImageReport;
import core_service.modules.library.api.dto.CatalogGameDto;
import core_service.modules.library.api.dto.CatalogGameDetailDto;
import core_service.modules.library.api.dto.CatalogPreviewDto;
import core_service.modules.library.api.dto.CatalogHitDto;
import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.CorrectionDto;
import core_service.modules.library.api.dto.DashboardGamesDto;
import core_service.modules.library.api.dto.FinishedHintDismissal;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.HiddenUpdateDto;
@@ -38,6 +40,7 @@ import core_service.modules.library.domain.CatalogGameDetail;
import core_service.modules.library.domain.CatalogHit;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.Mood;
import core_service.modules.library.domain.PickSource;
@@ -99,7 +102,9 @@ public class LibraryController {
private static final int MAX_ACTIVITY_DAYS = 90;
private static final int MAX_PICKER_EXCLUDES = 100;
private static final int MAX_BACKLOG_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority");
private static final int MAX_DASHBOARD_GAMES = 20;
private static final Set<String> SORTABLE_FIELDS = Set.of("playtimeMinutes", "firstSeenAt", "lastSeenAt", "appid", "rating", "priority",
"name", "lastPlayedAt", "timeToBeat");
private final LibrarySyncService syncService;
private final LibraryQueryService queryService;
@@ -148,6 +153,16 @@ public class LibraryController {
return ResponseEntity.ok(PageDto.of(dtoPage));
}
@GetMapping("/dashboard-games")
public ResponseEntity<DashboardGamesDto> getDashboardGames(@AuthenticationPrincipal UserPrincipal principal,
@RequestParam(defaultValue = "10") int top,
@RequestParam(defaultValue = "8") int playing) {
Long userId = principal.getIdAsLong();
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
return ResponseEntity.ok(queryService.listDashboardGames(userId, Math.clamp(top, 1, MAX_DASHBOARD_GAMES),
Math.clamp(playing, 1, MAX_DASHBOARD_GAMES)));
}
private Sort parseSort(String sort) {
Sort fallback = Sort.by(Sort.Direction.DESC, "playtimeMinutes");
if (sort == null || sort.isBlank()) return fallback;
@@ -177,11 +192,20 @@ public class LibraryController {
}
}
@GetMapping("/games/{appid}/preview")
public ResponseEntity<?> getGamePreview(@PathVariable("appid") Long appid) {
return gameDetailService.preview(appid)
.<ResponseEntity<?>>map(p -> ResponseEntity.ok(new CatalogPreviewDto(p.appid(), p.name(), p.imageUrl(),
p.genres(), toDto(p.timeToBeat()))))
.orElseGet(LibraryController::unknownGame);
}
@GetMapping("/games/{appid}")
public ResponseEntity<?> getGameDetail(@AuthenticationPrincipal UserPrincipal principal,
@PathVariable("appid") Long appid) {
@PathVariable("appid") Long appid,
@RequestParam(defaultValue = "true") boolean refresh) {
Long userId = principal.getIdAsLong();
return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid)
return gameDetailService.getGameDetail(userId, principal.steamIdText().orElse(null), appid, refresh)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(GAME_NOT_FOUND));
@@ -354,13 +378,16 @@ public class LibraryController {
}
private static CatalogGameDetailDto toDto(CatalogGameDetail d) {
var t = d.timeToBeat();
return new CatalogGameDetailDto(d.appid(), d.name(), d.imageUrl(), d.speedrunUrl(), d.moods(), d.endless(),
d.genres(), t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(),
t.getNormallyMinutes(), t.getCompletelyMinutes()),
d.genres(), toDto(d.timeToBeat()),
d.corrections().stream().map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList());
}
private static GameDetailDto.TimeToBeatDto toDto(GameTimeToBeat t) {
return t == null ? null : new GameDetailDto.TimeToBeatDto(t.getHastilyMinutes(), t.getNormallyMinutes(),
t.getCompletelyMinutes());
}
private static CatalogHitDto toDto(CatalogHit hit) {
return new CatalogHitDto(hit.appid(), hit.name(), hit.imageUrl(), hit.platforms());
}
@@ -11,6 +11,7 @@ import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
@@ -38,6 +39,17 @@ public class PublicProfileController {
.orElseGet(PublicProfileController::invalidTicket);
}
@GetMapping("/library/games/{appid}")
public ResponseEntity<?> getPublicGame(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@PathVariable("appid") long appid) {
return verify(ticket)
.<ResponseEntity<?>>map(t -> queryService.getPublicGame(t.userId(), t.sections(), appid)
.<ResponseEntity<?>>map(ResponseEntity::ok)
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
.body(new ErrorResponse("GAME_NOT_FOUND", "No such game on this profile"))))
.orElseGet(PublicProfileController::invalidTicket);
}
@GetMapping("/library/stats/games")
public ResponseEntity<?> getPublicStatGames(@RequestHeader(value = "X-Profile-Ticket", required = false) String ticket,
@RequestParam StatKey key,
@@ -0,0 +1,13 @@
package core_service.modules.library.api.dto;
import java.util.List;
/**
* A game the caller may not own: catalog data only, nothing per user.
*
* @param appid the kept key when the asked key was merged
* @param timeToBeat null when unknown
*/
public record CatalogPreviewDto(long appid, String name, String imageUrl, List<String> genres,
GameDetailDto.TimeToBeatDto timeToBeat) {
}
@@ -0,0 +1,8 @@
package core_service.modules.library.api.dto;
import java.util.List;
/**
* The MOST_PLAYED and PLAYING tiles: listed entries by set playtime, most first; a game can be in both.
*/
public record DashboardGamesDto(List<GameDto> top, List<GameDto> playing) {}
@@ -61,7 +61,9 @@ public record GameDetailDto(
// one line per platform of the entry, Steam first; empty for a hidden game
List<PlatformPlayDto> platforms,
// the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own
int totalPlaytimeMinutes
int totalPlaytimeMinutes,
// true when this answer skipped a due Steam refresh (refresh=false); the same call without it refreshes
boolean achievementsStale
) {
/**
@@ -0,0 +1,14 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameStatus;
/**
* One game of a public profile. A field is null when a hidden section covers it or the value is unknown.
*
* @param status hidden with the STATUS, PLAYING and FINISHED sections
* @param playtimeMinutes hidden with the SUMMARY section, like the achievements
* @param achievementsUnlocked null when the game has no achievements, Steam hides them or none were fetched
*/
public record PublicGameDto(long appid, String name, String imageUrl, GameStatus status, Integer playtimeMinutes,
Integer achievementsUnlocked, Integer achievementsTotal, Double completionPercent) {
}
@@ -0,0 +1,11 @@
package core_service.modules.library.domain;
import java.util.List;
/**
* Catalog data of one game key, the same for every user.
*
* @param timeToBeat null when unknown
*/
public record CatalogPreview(long appid, String name, String imageUrl, List<String> genres, GameTimeToBeat timeToBeat) {
}
@@ -1,10 +1,17 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* A PlayStation title the sync has seen for a user (V39). A LINKED title has the library key of the row PlayStation
* made or feeds; STEAM_OWNED has the key of a Steam row it only matched; DISMISSED titles were left unchecked in a review.
* The stored play (playtimeMinutes, lastPlayedAt) is what the last sync saw; both are null until the first savePlays.
*/
public record PsnTitle(String psnKey, State state, Long appid) {
public record PsnTitle(String psnKey, State state, Long appid, Integer playtimeMinutes, Instant lastPlayedAt) {
public PsnTitle(String psnKey, State state, Long appid) {
this(psnKey, state, appid, null, null);
}
public enum State { LINKED, STEAM_OWNED, DISMISSED }
}
@@ -309,7 +309,8 @@ public class GameLinkRepository {
/**
* @return per given entry key, the platforms of its editions (see V43): Steam, PlayStation, then manual, by
* platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform
* platform name; a PlayStation platform without a name is left out when the entry has a named PlayStation one, or its own member row has a manual platform.
* The view is read once: each reference costs ~16 ms of planning (nested views), more than the query runs.
*/
public Map<Long, List<EntryPlatform>> entryPlatforms(long userId, Collection<Long> appids) {
Map<Long, List<EntryPlatform>> platforms = new HashMap<>();
@@ -317,12 +318,14 @@ public class GameLinkRepository {
return platforms;
}
jdbc.query("""
with p as materialized (
select appid, member_appid, source, platform from user_game_entry_platform
where user_id = :user and appid in (:appids))
select appid, source, platform
from user_game_entry_platform p
where user_id = :user and appid in (:appids)
and not (source = 'PSN' and platform = '' and exists (
select 1 from user_game_entry_platform n
where n.user_id = p.user_id and n.appid = p.appid and n.platform <> ''
from p
where not (source = 'PSN' and platform = '' and exists (
select 1 from p n
where n.appid = p.appid and n.platform <> ''
and (n.source = 'PSN' or (n.source = 'MANUAL' and n.member_appid = p.member_appid))))
group by appid, source, platform
order by appid, case source when 'STEAM' then 0 when 'PSN' then 1 else 2 end, platform
@@ -264,6 +264,13 @@ public class GameMergeRepository {
return targets;
}
/**
* @return the key a merged key now lives under, else the key itself
*/
public long findTarget(long appid) {
return findTargets(List.of(appid)).getOrDefault(appid, appid);
}
/**
* @return the saved copy of a merge, locked until the transaction ends; empty when there is none
*/
@@ -433,7 +440,7 @@ public class GameMergeRepository {
insert into user_game_playtime_day (user_id, day, appid, minutes)
select user_id, day, :to, minutes from user_game_playtime_day where user_id = :user and appid = :from
on conflict (user_id, day, appid) do update
set minutes = user_game_playtime_day.minutes + excluded.minutes
set minutes = least(user_game_playtime_day.minutes + excluded.minutes, 1440)
""", params);
jdbc.update("delete from user_game_playtime_day where user_id = :user and appid = :from", params);
jdbc.update("update user_psn_title set appid = :to where user_id = :user and appid = :from", params);
@@ -25,10 +25,11 @@ public class PsnTitleRepository {
public Map<String, PsnTitle> findAll(long userId) {
Map<String, PsnTitle> titles = new LinkedHashMap<>();
jdbc.query("select psn_key, state, appid from user_psn_title where user_id = :user",
jdbc.query("select psn_key, state, appid, playtime_minutes, last_played_at from user_psn_title where user_id = :user",
new MapSqlParameterSource("user", userId), rs -> {
titles.put(rs.getString("psn_key"), new PsnTitle(rs.getString("psn_key"),
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class)));
PsnTitle.State.valueOf(rs.getString("state")), rs.getObject("appid", Long.class),
rs.getObject("playtime_minutes", Integer.class), instant(rs.getTimestamp("last_played_at"))));
});
return titles;
}
@@ -10,6 +10,7 @@ import core_service.modules.library.domain.RemovedGame;
import core_service.modules.library.domain.StatusCount;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.shared.sql.LikePattern;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
@@ -28,7 +29,6 @@ import java.util.EnumMap;
import java.util.EnumSet;
import java.util.List;
import java.util.Map;
import java.util.Locale;
import java.util.Optional;
public interface UserGameRepository extends JpaRepository<UserGame, UserGameId> {
@@ -366,7 +366,8 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
// ponytail: the view is joined on every page, not only for the playtime sort: about +4 ms per page. Upgrade path:
// join it only for the playtime sort or the PLAYING status, with a second query.
@Query(value = "select ug from UserGame ug join SteamGame sg on sg.appid = ug.appid " +
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " + OWNED_WHERE,
"left join UserGameEntryPlaytime ep on ep.userId = ug.userId and ep.appid = ug.appid " +
"left join GameTimeToBeat tb on tb.appid = ug.appid " + OWNED_WHERE,
countQuery = "select count(ug) from UserGame ug join SteamGame sg on sg.appid = ug.appid " + OWNED_WHERE)
Page<UserGame> findOwnedEntries(@Param("userId") Long userId,
@Param("hidden") boolean hidden,
@@ -418,7 +419,9 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
/**
* Makes a {@link #findOwnedGames} sort stable and meaningful: games without a rating or priority come last
* in both directions, priority sorts by HIGH, MEDIUM, LOW (the enum names would sort alphabetically), and
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total.
* appid breaks every tie so pages do not repeat or skip games. Playtime sorts by the set total. A game never
* played or without a known time to beat comes last in both directions; the name sorts case-insensitive.
* The time to beat needs {@code left join GameTimeToBeat tb on tb.appid = ug.appid}.
*/
static Sort querySort(Sort requested) {
Sort result = Sort.unsorted();
@@ -430,6 +433,10 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
case "priority" -> Sort.by(JpaSort.unsafe(order.getDirection(),
"(case ug.priority when 'HIGH' then 3 when 'MEDIUM' then 2 when 'LOW' then 1 end)")
.iterator().next().nullsLast());
case "name" -> Sort.by(JpaSort.unsafe(order.getDirection(), "lower(sg.name)").iterator().next());
case "lastPlayedAt" -> Sort.by(order.nullsLast());
case "timeToBeat" -> Sort.by(JpaSort.unsafe(order.getDirection(), "tb.normallyMinutes")
.iterator().next().nullsLast());
default -> Sort.by(order);
});
}
@@ -441,9 +448,7 @@ public interface UserGameRepository extends JpaRepository<UserGame, UserGameId>
*/
static String containsPattern(String query) {
if (query == null || query.isBlank()) return null;
String escaped = query.trim().toLowerCase(Locale.ROOT)
.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
return "%" + escaped + "%";
return "%" + LikePattern.escapeLower(query.trim()) + "%";
}
/**
@@ -103,7 +103,7 @@ public class UserStatsRepository {
private static final String PILE_TTB_SET = "select ug.appid, t.normally_minutes as minutes, " + NO_EVENT + ", "
+ NO_MOVE + " " + PILE_TTB_FROM + "and t.normally_minutes is not null";
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, sum(p.minutes) as minutes, "
private static final String LONGEST_DAY_SET = "select coalesce(e.appid, p.appid) as appid, least(sum(p.minutes), 1440) as minutes, "
+ NO_EVENT + ", " + NO_MOVE + " " + ENTRY_DAYS + "and p.day = :arg group by coalesce(e.appid, p.appid)";
private static final String FINISHED_YEAR_SET = "select ug.appid, " + NO_MINUTES + ", " + FINISHED_AT
+ " as event_at, " + NO_MOVE + " " + FINISHED_FROM + "and " + FINISHED_YEAR + " = :arg";
@@ -242,7 +242,7 @@ public class UserStatsRepository {
}
public Optional<DayMinutes> longestDay(long userId) {
return jdbc.query("select p.day, sum(p.minutes) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
return jdbc.query("select p.day, least(sum(p.minutes), 1440) as minutes " + PLAYTIME_DAYS_NOT_HIDDEN +
"group by p.day order by minutes desc, p.day desc limit 1", user(userId),
(rs, i) -> new DayMinutes(rs.getDate("day").toLocalDate(), rs.getLong("minutes")))
.stream().findFirst();
@@ -134,12 +134,26 @@ public class AchievementSyncService {
}
public void refreshSchemaIfStale(Long appid) {
Optional<GameAchievementSchemaMeta> existing = schemaMetaRepository.findById(appid);
if (existing.isEmpty() || isStale(existing.get())) {
if (schemaStale(appid)) {
refreshSchema(appid, false, false);
}
}
/**
* @return true when the schema meta of the game is missing or older than the schema TTL
*/
public boolean schemaStale(Long appid) {
return schemaMetaRepository.findById(appid).map(this::isStale).orElse(true);
}
/**
* @return true when the user's achievement meta of the game is missing or older than ttl
*/
public boolean userStale(Long userId, Long appid, Duration ttl) {
return userAchievementMetaRepository.findByUserIdAndAppid(userId, appid)
.map(meta -> meta.getRefreshedAt().isBefore(Instant.now().minus(ttl))).orElse(true);
}
/**
* Sets FINISHED when the user unlocked the ending achievement (see {@link EndingAchievement}) of a Steam game whose
* status they never chose. Uses stored rows only.
@@ -168,8 +182,7 @@ public class AchievementSyncService {
}
public void refreshUserIfStale(Long userId, String steamId, Long appid, int playtimeMinutes, Duration ttl) {
Optional<UserGameAchievementMeta> existing = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid);
if (existing.isEmpty() || existing.get().getRefreshedAt().isBefore(Instant.now().minus(ttl))) {
if (userStale(userId, appid, ttl)) {
refreshUser(userId, steamId, appid, playtimeMinutes);
}
}
@@ -9,6 +9,7 @@ import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.CatalogPreview;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema;
@@ -98,6 +99,15 @@ public class GameDetailService {
* @param steamId null when the user has none; then the stored achievements are shown without a refresh
*/
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long askedAppid) {
return getGameDetail(userId, steamId, askedAppid, true);
}
/**
* @param refreshAchievements false answers from stored data and only says in {@code achievementsStale} that the
* Steam refresh is due
*/
public Optional<GameDetailDto> getGameDetail(Long userId, String steamId, Long askedAppid,
boolean refreshAchievements) {
Long appid = askedAppid;
Optional<UserGame> userGameOpt = userGameRepository.findById(new UserGameId(userId, appid));
if (userGameOpt.isEmpty()) {
@@ -112,17 +122,31 @@ public class GameDetailService {
return Optional.empty();
}
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
Refreshed refreshed = refresh(userGameOpt.get(), steamId);
return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema()));
Refreshed refreshed = refresh(userGameOpt.get(), steamId, refreshAchievements);
return Optional.of(assemble(refreshed.userGame(), meta, refreshed.hasSchema(), refreshed.achievementsStale()));
}
/**
* Catalog data of a key the caller may not own. No call to Steam or IGDB.
*
* @return empty when the catalog has no such game
*/
public Optional<CatalogPreview> preview(long askedAppid) {
long appid = mergeRepository.findTarget(askedAppid);
return steamGameRepository.findById(appid).map(game -> new CatalogPreview(appid, game.getName(),
game.getImageUrl(),
gameMoodsRepository.findById(appid).map(GameMoods::getGenres).orElse(List.of()),
gameTimeToBeatRepository.findById(appid).orElse(null)));
}
/**
* @param userGame the game after the refresh, re-read when the finish rule changed its status
* @param achievementsStale a due Steam refresh was skipped
*/
private record Refreshed(UserGame userGame, boolean hasSchema) {}
private record Refreshed(UserGame userGame, boolean hasSchema, boolean achievementsStale) {}
// May call Steam, IGDB and speedrun.com and write.
private Refreshed refresh(UserGame userGame, String steamId) {
private Refreshed refresh(UserGame userGame, String steamId, boolean refreshAchievements) {
Long userId = userGame.getUserId();
Long appid = userGame.getAppid();
// A hidden game gets no calls to Steam, IGDB or speedrun.com; it shows what is stored.
@@ -138,42 +162,64 @@ public class GameDetailService {
timeToBeatService.refreshInBackground(List.of(appid));
moodService.refreshInBackground(List.of(appid));
releaseDateService.refreshInBackground(List.of(appid));
return new Refreshed(userGame, false);
return new Refreshed(userGame, false, false);
}
if (!hidden) {
try {
achievementSyncService.refreshSchemaIfStale(appid);
} catch (Exception e) {
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
}
}
boolean stale = !hidden && refreshSchemaIfAsked(appid, refreshAchievements);
boolean hasSchema = schemaMetaRepository.findById(appid).map(GameAchievementSchemaMeta::isHasSchema).orElse(false);
if (!hasSchema) {
return new Refreshed(userGame, false);
return new Refreshed(userGame, false, stale);
}
if (steamId != null && !hidden) {
try {
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(),
Duration.ofHours(achievementTtlHours));
} catch (Exception e) {
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
}
stale |= refreshUserIfAsked(userGame, steamId, refreshAchievements);
}
if (!hidden && !userGame.isStatusSetByUser() && achievementSyncService.finishIfEndingUnlocked(userId, appid)) {
userGame = userGameRepository.findById(new UserGameId(userId, appid)).orElse(userGame);
}
return new Refreshed(userGame, true);
return new Refreshed(userGame, true, stale);
}
/**
* @return true when the refresh was not asked and is due
*/
private boolean refreshSchemaIfAsked(Long appid, boolean refreshAchievements) {
if (!refreshAchievements) {
return achievementSyncService.schemaStale(appid);
}
try {
achievementSyncService.refreshSchemaIfStale(appid);
} catch (Exception e) {
log.warn("Achievement schema of appid={} not refreshed, showing stored data: {}", appid, e.getMessage());
}
return false;
}
/**
* @return true when the refresh was not asked and is due
*/
private boolean refreshUserIfAsked(UserGame userGame, String steamId, boolean refreshAchievements) {
Long userId = userGame.getUserId();
Long appid = userGame.getAppid();
Duration ttl = Duration.ofHours(achievementTtlHours);
if (!refreshAchievements) {
return achievementSyncService.userStale(userId, appid, ttl);
}
try {
achievementSyncService.refreshUserIfStale(userId, steamId, appid, userGame.getPlaytimeMinutes(), ttl);
} catch (Exception e) {
log.warn("Achievements of user={} appid={} not refreshed, showing stored data: {}", userId, appid, e.getMessage());
}
return false;
}
// Reads only.
private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema) {
private GameDetailDto assemble(UserGame userGame, SteamGame meta, boolean hasSchema, boolean achievementsStale) {
List<AchievementSetDto> psnSets = psnSets(userGame);
List<AchievementDto> psnTrophies = psnSets.isEmpty() ? List.of() : psnTrophies(userGame);
if (!hasSchema) {
return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies);
return buildDto(userGame, meta, true, 0, 0, List.of(), List.of(), psnSets, psnTrophies, achievementsStale);
}
Long userId = userGame.getUserId();
Long appid = userGame.getAppid();
@@ -206,7 +252,8 @@ public class GameDetailService {
.toList();
return buildDto(userGame, meta, statsVisible, schemaList.size(),
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies);
AchievementProgress.capped(unlocked, schemaList.size()), achievementDtos, schemaList, psnSets, psnTrophies,
achievementsStale);
}
private List<AchievementSetDto> psnSets(UserGame userGame) {
@@ -233,7 +280,8 @@ public class GameDetailService {
private GameDetailDto buildDto(UserGame userGame, SteamGame meta, boolean statsVisible, int total, int unlocked,
List<AchievementDto> achievements, List<GameAchievementSchema> schema,
List<AchievementSetDto> psnSets, List<AchievementDto> psnTrophies) {
List<AchievementSetDto> psnSets, List<AchievementDto> psnTrophies,
boolean achievementsStale) {
Optional<GameTimeToBeat> timeToBeat = gameTimeToBeatRepository.findById(userGame.getAppid());
Set<String> unlockedNames = achievements.stream().filter(AchievementDto::unlocked).map(AchievementDto::apiName)
.collect(Collectors.toSet());
@@ -299,7 +347,8 @@ public class GameDetailService {
coming.map(ComingGame::getReleaseDate).orElse(null),
sets,
plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(),
PlatformPlay.total(plays)
PlatformPlay.total(plays),
achievementsStale
);
}
@@ -3,6 +3,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementSummaryDto;
import core_service.modules.library.api.dto.ActivityDto;
import core_service.modules.library.api.dto.BacklogOrderDto;
import core_service.modules.library.api.dto.DashboardGamesDto;
import core_service.modules.library.api.dto.GameDto;
import core_service.modules.library.api.dto.LibrarySummaryDto;
import core_service.modules.library.api.dto.PickReasonDto;
@@ -10,6 +11,7 @@ import core_service.modules.library.api.dto.PickedGameDto;
import core_service.modules.library.api.dto.PickerCategoryDto;
import core_service.modules.library.api.dto.PickerDto;
import core_service.modules.library.api.dto.PlatformDto;
import core_service.modules.library.api.dto.PublicGameDto;
import core_service.modules.library.api.dto.PublicLibraryDto;
import core_service.modules.library.api.dto.RecentGameDto;
import core_service.modules.library.api.dto.WishlistDto;
@@ -38,6 +40,7 @@ import core_service.modules.library.repo.FriendPlayRepository;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameCreditRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.GameMergeRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.PlaytimeDayRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
@@ -107,6 +110,7 @@ public class LibraryQueryService {
private final ReleaseDateService releaseDateService;
private final SpeedrunService speedrunService;
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final GameCreditRepository gameCreditRepository;
private final FriendPlayRepository friendPlayRepository;
private final PsnTrophyRepository psnTrophyRepository;
@@ -134,6 +138,20 @@ public class LibraryQueryService {
return new PageImpl<>(games, page.getPageable(), page.getTotalElements());
}
/**
* The same rows and order as {@link #listOwnedGames} without a filter, sorted {@code playtimeMinutes,desc} (top)
* and with {@code status=PLAYING} (playing), but without the count query; one enrichment for both lists.
*/
public DashboardGamesDto listDashboardGames(Long userId, int topLimit, int playingLimit) {
Sort byPlaytime = UserGameRepository.querySort(Sort.by(Sort.Direction.DESC, "playtimeMinutes"));
List<UserGame> top = userGameRepository.findListedByStatusIn(userId, EnumSet.allOf(GameStatus.class),
PageRequest.of(0, topLimit, byPlaytime));
List<UserGame> playing = userGameRepository.findListedByStatusIn(userId, List.of(GameStatus.PLAYING),
PageRequest.of(0, playingLimit, byPlaytime));
List<GameDto> dtos = toDtos(Stream.concat(top.stream(), playing.stream()).toList());
return new DashboardGamesDto(dtos.subList(0, top.size()), dtos.subList(top.size(), dtos.size()));
}
/**
* One row per {@link PickSource} (next up, next in series, because you played, same studio, friends played,
* longest waiting, mood), in the order of its constants, each with up to three backlog games. Budget and exclude
@@ -375,7 +393,8 @@ public class LibraryQueryService {
Map<LocalDate, Long> played = playtimeDayRepository.minutesPerDay(userId, from).stream()
.collect(Collectors.toMap(DayMinutes::day, DayMinutes::minutes));
List<DayMinutes> perDay = from.datesUntil(today.plusDays(1))
.map(day -> new DayMinutes(day, played.getOrDefault(day, 0L)))
// A day holds at most 24 h; rows from before V18/V49 or several games can sum higher.
.map(day -> new DayMinutes(day, Math.min(played.getOrDefault(day, 0L), 1440)))
.toList();
long total = perDay.stream().mapToLong(DayMinutes::minutes).sum();
@@ -432,6 +451,64 @@ public class LibraryQueryService {
shared.isEmpty() ? null : userStatsService.getStats(userId, shared));
}
/**
* One listed game of a public profile. Loads the achievement rows of the whole library of the owner and keeps
* the one game.
*
* @param sectionNames see {@link ProfileSection#shown}
* @return empty when the owner does not have the game, removed or hid it, or every field is hidden: all the same
* answer, so the caller learns nothing about a hidden game
*/
@Public
public Optional<PublicGameDto> getPublicGame(Long userId, Collection<String> sectionNames, long askedAppid) {
Set<ProfileSection> sections = ProfileSection.shown(sectionNames);
boolean showStatus = sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.PLAYING)
|| sections.contains(ProfileSection.FINISHED);
boolean showSummary = sections.contains(ProfileSection.SUMMARY);
if (!showStatus && !showSummary) {
return Optional.empty();
}
return userGameRepository.findById(new UserGameId(userId, mergeRepository.findTarget(askedAppid)))
.filter(ug -> ug.getRemovedAt() == null && ug.getHiddenAt() == null)
.filter(ug -> showsEveryGame(sections) || listedBySection(sections, ug.getStatus()))
.map(userGame -> toPublicGame(userId, userGame, showStatus, showSummary));
}
/** STATUS and SUMMARY already expose the whole library (status lists, totals). */
private static boolean showsEveryGame(Set<ProfileSection> sections) {
return sections.contains(ProfileSection.STATUS) || sections.contains(ProfileSection.SUMMARY);
}
/** Only PLAYING or FINISHED shown: a game is revealed only when a shown section already lists its status. */
private static boolean listedBySection(Set<ProfileSection> sections, GameStatus status) {
return sections.contains(ProfileSection.PLAYING) && status == GameStatus.PLAYING
|| sections.contains(ProfileSection.FINISHED)
&& (status == GameStatus.FINISHED || status == GameStatus.COMPLETED);
}
private PublicGameDto toPublicGame(Long userId, UserGame userGame, boolean showStatus, boolean showSummary) {
long appid = userGame.getAppid();
SteamGame meta = steamGameRepository.findById(appid).orElse(null);
Integer playtime = showSummary
? gameLinkRepository.entryPlaytimes(userId, List.of(appid)).getOrDefault(appid, userGame.getPlaytimeMinutes())
: null;
AchievementProgress achievements = showSummary ? visibleAchievements(userId, appid) : null;
return new PublicGameDto(appid, meta == null ? null : meta.getName(), meta == null ? null : meta.getImageUrl(),
showStatus ? userGame.getStatus() : null, playtime,
achievements == null ? null : achievements.unlockedCapped(),
achievements == null ? null : achievements.total(),
achievements == null ? null : achievements.percent());
}
/**
* @return null when the game has no achievements or Steam hides them
*/
private AchievementProgress visibleAchievements(Long userId, long appid) {
return AchievementProgress.perGame(progress(userId).stream()
.filter(p -> p.appid() == appid && p.hasAchievements() && p.visible()).toList())
.stream().findFirst().orElse(null);
}
/**
* @return null for a hidden section
*/
@@ -164,7 +164,7 @@ public class LibrarySyncWorker {
}
List<SyncChange> steamChanges = hasSteam ? saveGames(userId, steamId, games, syncStartedAt, playedOn)
: new ArrayList<>();
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay);
List<SyncChange> psnChanges = psnSyncStep.apply(userId, psn, syncStartedAt, syncDay, zone);
// The first sync of an account is a baseline: a count, not a list.
int imported = 0;
List<SyncChange> listed = new ArrayList<>();
@@ -22,6 +22,7 @@ import org.springframework.transaction.support.TransactionTemplate;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
@@ -47,17 +48,22 @@ public class PsnSyncStep {
* A LINKED title's PSN values, applied in the sync transaction.
*
* @param lastPlayedAt may be null
* @param played minutes to book on the day of lastPlayedAt, 0 when the title is a start point or was not played
* since the last sync
*/
public record Playtime(long appid, int minutes, Instant lastPlayedAt) {}
public record Playtime(long appid, int minutes, Instant lastPlayedAt, int played) {}
/**
* What {@link #run} did outside the transaction.
*
* @param changes the ADDED rows of this run
* @param plays the title plays {@link #apply} stores; stored there so a skipped or rolled-back apply keeps the
* old baseline
* @param baseline the user had no PSN title before this run
*/
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), false);
public record Prepared(List<SyncChange> changes, List<Playtime> playtimes, List<TitlePlay> plays,
boolean baseline) {
public static final Prepared NONE = new Prepared(List.of(), List.of(), List.of(), false);
}
private final PsnAccountService accountService;
@@ -106,26 +112,38 @@ public class PsnSyncStep {
List<GameImport.Row> review = new ArrayList<>();
for (GameImport.Line line : lines) {
PsnTitle seen = known.get(line.psnKey());
Instant lastPlayedAt = lastPlayed.get(line.psnKey());
if (seen != null && seen.state() == PsnTitle.State.LINKED) {
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayed.get(line.psnKey())));
playtimes.add(new Playtime(seen.appid(), line.playtimeMinutes(), lastPlayedAt,
played(seen, line.playtimeMinutes(), lastPlayedAt)));
} else if (seen == null && !open.contains(line.psnKey())) {
matchNew(userId, line, lastPlayed.get(line.psnKey()), changes, review);
matchNew(userId, line, lastPlayedAt, changes, review);
}
}
saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
List<TitlePlay> plays = lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList();
int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
review.size(), changes.size() - added);
return new Prepared(changes, mergedKeys(playtimes), baseline);
return new Prepared(changes, mergedKeys(playtimes), plays, baseline);
} catch (RuntimeException e) {
log.error("PSN step failed for user {}", userId, e);
return new Prepared(changes, playtimes, baseline);
return new Prepared(changes, playtimes, List.of(), baseline);
}
}
// Only the title's own new play counts: no stored play is a start point, and a total that grew without a newer
// last played is no play of this sync.
private static int played(PsnTitle seen, int minutes, Instant lastPlayedAt) {
if (seen.playtimeMinutes() == null || lastPlayedAt == null
|| (seen.lastPlayedAt() != null && !lastPlayedAt.isAfter(seen.lastPlayedAt()))) {
return 0;
}
return Math.max(0, minutes - seen.playtimeMinutes());
}
private Optional<PsnAccountService.FetchResult.Titles> fetch(Long userId) {
switch (accountService.fetchTitles(userId)) {
case PsnAccountService.FetchResult.Titles found -> {
@@ -194,7 +212,7 @@ public class PsnSyncStep {
private List<Playtime> mergedKeys(List<Playtime> playtimes) {
Map<Long, Long> targets = mergeRepository.findTargets(playtimes.stream().map(Playtime::appid).toList());
return playtimes.stream().map(playtime -> new Playtime(targets.getOrDefault(playtime.appid(), playtime.appid()),
playtime.minutes(), playtime.lastPlayedAt())).toList();
playtime.minutes(), playtime.lastPlayedAt(), playtime.played())).toList();
}
// Several titles can be one game (PS4 and PS5 versions, a review pick): its playtime is their sum, so the row
@@ -205,33 +223,35 @@ public class PsnSyncStep {
byGame.merge(playtime.appid(), playtime, (a, b) -> new Playtime(a.appid(),
(int) Math.min((long) a.minutes() + b.minutes(), Integer.MAX_VALUE),
a.lastPlayedAt() == null || (b.lastPlayedAt() != null && b.lastPlayedAt().isAfter(a.lastPlayedAt()))
? b.lastPlayedAt() : a.lastPlayedAt()));
? b.lastPlayedAt() : a.lastPlayedAt(),
(int) Math.min((long) a.played() + b.played(), Integer.MAX_VALUE)));
}
return byGame.values();
}
/**
* In the sync transaction, after the Steam games are saved: playtime, last played and two-weeks minutes of the
* PSN rows. No status change: PSN rows have statusSetByUser.
* PSN rows. The played minutes of a game are booked on the day it was last played, in the given zone. No status
* change: PSN rows have statusSetByUser.
*
* @return all PSN changes of the run (ADDED and PLAYTIME)
*/
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day) {
public List<SyncChange> apply(Long userId, Prepared prepared, Instant now, LocalDate day, ZoneId zone) {
List<SyncChange> changes = new ArrayList<>(prepared.changes());
Map<Long, UserGame> touched = new HashMap<>();
for (Playtime playtime : perGame(prepared.playtimes())) {
userGameRepository.findById(new UserGameId(userId, playtime.appid()))
.filter(game -> game.getSource() == GameSource.PSN && game.getRemovedAt() == null)
.ifPresent(game -> {
int growth = playtime.minutes() - game.getPlaytimeMinutes();
game.setPlaytimeMinutes(playtime.minutes());
game.setLastSeenAt(now);
if (playtime.lastPlayedAt() != null) {
game.setLastPlayedAt(playtime.lastPlayedAt());
}
if (growth > 0) {
changes.add(SyncChange.playtime(game.getAppid(), growth));
playtimeDayRepository.addMinutes(userId, game.getAppid(), day, growth);
if (playtime.played() > 0) {
LocalDate playedOn = LocalDate.ofInstant(playtime.lastPlayedAt(), zone);
changes.add(SyncChange.playtime(game.getAppid(), playtime.played()));
playtimeDayRepository.addMinutes(userId, game.getAppid(), playedOn, playtime.played());
}
touched.put(game.getAppid(), game);
});
@@ -243,6 +263,9 @@ public class PsnSyncStep {
touched.forEach((appid, game) ->
game.setPlaytime2Weeks((int) Math.min(twoWeeks.getOrDefault(appid, 0L), Integer.MAX_VALUE)));
}
if (!prepared.plays().isEmpty()) {
psnTitleRepository.savePlays(userId, prepared.plays());
}
return changes;
}
}
@@ -10,6 +10,7 @@ import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.modules.speedrun_adapter.service.SpeedrunApiService;
import core_service.modules.speedrun_adapter.service.SpeedrunThrottledException;
import core_service.shared.background.BackgroundPools;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -42,6 +43,9 @@ import java.util.concurrent.atomic.AtomicBoolean;
* year of its IGDB parent game, a game without a parent by its name without an edition suffix. A sure hit without
* verified runs ends the lookup. See {@link SpeedrunMatch} for "sure".
* <p>
* A rate limit answer (429, 420) pauses the thread (Retry-After, else 1 min doubling up to 30 min) and asks the same
* game again; it never drops the queues.
* <p>
* {@link #recheckMarked} asks the rows a migration marked (checked_at = epoch) again. Those games wait behind every
* game a user asked for.
*/
@@ -55,6 +59,8 @@ public class SpeedrunService {
static final int MAX_ERRORS_IN_A_ROW = 3;
// A small batch, so a user request waits for a few games and not for 50.
static final int RECHECK_BATCH = 10;
static final Duration FIRST_PAUSE = Duration.ofMinutes(1);
static final Duration MAX_PAUSE = Duration.ofMinutes(30);
private final SpeedrunApiService speedrunApiService;
private final IgdbApiService igdbApiService;
@@ -75,7 +81,7 @@ public class SpeedrunService {
/**
* Queues the games without a fresh row and returns; the speedrun thread works the queue off. A game takes up to 3
* requests, and there is 1 request per second.
* requests, and there is 1 request every 2 seconds.
*/
public void refresh(Collection<Long> appids) {
if (!speedrunApiService.isEnabled() || appids.isEmpty()) return;
@@ -107,7 +113,7 @@ public class SpeedrunService {
running.set(false);
}
// Games can be queued after the last poll and before the flag was cleared.
if (pending.isEmpty() && recheck.isEmpty()) return;
if (Thread.currentThread().isInterrupted() || (pending.isEmpty() && recheck.isEmpty())) return;
}
}
@@ -136,6 +142,7 @@ public class SpeedrunService {
private void drain() {
int errorsInARow = 0;
int throttlesInARow = 0;
while (!pending.isEmpty() || !recheck.isEmpty()) {
boolean forUsers = !pending.isEmpty();
List<Long> batch = forUsers ? poll(pending, BATCH) : poll(recheck, RECHECK_BATCH);
@@ -165,12 +172,25 @@ public class SpeedrunService {
boolean stored;
try {
stored = checkAndStore(appid, name, years.get().getOrDefault(appid, Set.of()), parents.get().get(appid));
} catch (SpeedrunThrottledException e) {
Duration pause = pauseFor(e.getRetryAfter(), ++throttlesInARow);
log.warn("speedrun.com rate limit, pausing {} ({} games queued)", pause,
pending.size() + recheck.size() + batch.size() - i);
try {
pause(pause);
} catch (InterruptedException stop) {
Thread.currentThread().interrupt();
return;
}
i--; // same game again, after the user check at the loop head
continue;
} catch (Exception e) {
log.warn("Speedrun game of appid={} not stored: {}", appid, e.getMessage());
stored = false;
}
if (stored) {
errorsInARow = 0;
throttlesInARow = 0;
} else if (++errorsInARow >= MAX_ERRORS_IN_A_ROW) {
log.warn("Speedrun check failed {} times in a row, {} queued games dropped", errorsInARow, pending.size());
pending.clear();
@@ -181,6 +201,22 @@ public class SpeedrunService {
}
}
/**
* @param retryAfter the pause speedrun.com asked for; null when it did not say
* @param throttlesInARow throttled answers since the last stored game, at least 1
*/
static Duration pauseFor(Duration retryAfter, int throttlesInARow) {
if (retryAfter != null) {
return Duration.ofSeconds(Math.clamp(retryAfter.toSeconds(), 1, MAX_PAUSE.toSeconds()));
}
Duration backoff = FIRST_PAUSE.multipliedBy(1L << Math.min(throttlesInARow - 1, 5));
return backoff.compareTo(MAX_PAUSE) > 0 ? MAX_PAUSE : backoff;
}
void pause(Duration pause) throws InterruptedException {
Thread.sleep(pause);
}
private static List<Long> poll(ConcurrentSkipListSet<Long> queue, int size) {
List<Long> batch = new ArrayList<>();
Long next;
@@ -7,9 +7,12 @@ import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpStatusCodeException;
import org.springframework.web.client.RestClient;
import java.time.Duration;
import java.util.List;
import java.util.Optional;
@@ -45,6 +48,8 @@ public class SpeedrunApiService {
/**
* Games whose name looks like the given one, in speedrun.com's order, without mods and category extensions.
* Empty when the adapter is off or the request failed.
*
* @throws SpeedrunThrottledException on a rate limit answer (429, 420)
*/
public Optional<List<Game>> findGames(String name) {
if (!enabled || name == null || name.isBlank()) {
@@ -67,6 +72,7 @@ public class SpeedrunApiService {
.map(game -> new Game(game.id(), game.names().international().trim(), game.released(), game.weblink()))
.toList());
} catch (Exception e) {
throwIfThrottle(e);
log.warn("speedrun.com search for \"{}\" failed: {}", name, e.getMessage());
return Optional.empty();
}
@@ -74,6 +80,8 @@ public class SpeedrunApiService {
/**
* Empty when the adapter is off or the request failed.
*
* @throws SpeedrunThrottledException on a rate limit answer (429, 420)
*/
public Optional<Boolean> hasVerifiedRuns(String gameId) {
if (!enabled) {
@@ -90,8 +98,26 @@ public class SpeedrunApiService {
}
return Optional.of(!runs.data().isEmpty());
} catch (Exception e) {
throwIfThrottle(e);
log.warn("speedrun.com runs of game {} failed: {}", gameId, e.getMessage());
return Optional.empty();
}
}
private static void throwIfThrottle(Exception e) {
if (!(e instanceof HttpStatusCodeException http)) return;
int status = http.getStatusCode().value();
if (status != 429 && status != 420) return;
Duration retryAfter = null;
HttpHeaders headers = http.getResponseHeaders();
String header = headers == null ? null : headers.getFirst(HttpHeaders.RETRY_AFTER);
if (header != null) {
try {
retryAfter = Duration.ofSeconds(Long.parseLong(header.trim()));
} catch (NumberFormatException ignored) {
// an HTTP-date: the caller backs off on its own
}
}
throw new SpeedrunThrottledException(retryAfter);
}
}
@@ -0,0 +1,22 @@
package core_service.modules.speedrun_adapter.service;
import lombok.Getter;
import java.time.Duration;
/**
* speedrun.com asked us to slow down: 429 (its Cloudflare) or 420 (the documented API limit).
*/
@Getter
public class SpeedrunThrottledException extends RuntimeException {
/**
* From the Retry-After header; null when it is missing or not a number of seconds.
*/
private final Duration retryAfter;
public SpeedrunThrottledException(Duration retryAfter) {
super("speedrun.com rate limit");
this.retryAfter = retryAfter;
}
}
@@ -0,0 +1,16 @@
package core_service.shared.sql;
import java.util.Locale;
public final class LikePattern {
private LikePattern() {}
/**
* Lower-cases the text and escapes {@code \}, {@code %} and {@code _}, so a {@code like ... escape '\'} matches
* them literally. The caller adds the wildcards.
*/
public static String escapeLower(String text) {
return text.toLowerCase(Locale.ROOT).replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_");
}
}
+10 -2
View File
@@ -8,6 +8,12 @@ spring:
accept-float-as-int: false
jpa:
open-in-view: false
datasource:
hikari:
# Plan once, not per request: Dashboard burst 443 -> 229 ms, 94% of the DB time was planning (#49).
data-source-properties:
prepareThreshold: 1
connection-init-sql: set plan_cache_mode = force_generic_plan
flyway:
enabled: true
baseline-on-migrate: true
@@ -100,6 +106,8 @@ app:
# The hand changes of a followed user are one batch after this quiet time.
digest-ms: 600000
quiet-minutes: 10
# Player searches per user and minute; more answers 429.
search-per-minute: 30
inbox:
purge-cron: "0 15 3 * * *"
keep-days: 90
@@ -135,9 +143,9 @@ resilience4j:
limit-refresh-period: 1s
timeout-duration: 5s
speedrunApi:
# speedrun.com allows 100 requests/min per IP.
# speedrun.com documents 100 requests/min per IP, but its Cloudflare answered 429 (1015) at 60/min: 30/min.
limit-for-period: 1
limit-refresh-period: 1s
limit-refresh-period: 2s
timeout-duration: 30s
igdbApi:
# IGDB allows 4 requests/s. Taken per HTTP request.
@@ -0,0 +1,12 @@
-- What a DIGEST note counted (#27 follow-up). Names are snapshots; the row goes with its note.
CREATE TABLE inbox_note_detail (
id BIGSERIAL PRIMARY KEY,
note_id BIGINT NOT NULL REFERENCES inbox_note (id) ON DELETE CASCADE,
kind VARCHAR(16) NOT NULL CHECK (kind IN ('NEW_GAME', 'ACHIEVEMENT', 'STATUS', 'WISHLIST')),
appid BIGINT NOT NULL,
game_name VARCHAR(512), -- steam_game.name / wishlist_game.name at write time
item_name VARCHAR(512), -- ACHIEVEMENT: achievement or trophy name
from_status VARCHAR(16), -- STATUS only
to_status VARCHAR(16)
);
CREATE INDEX idx_inbox_note_detail_note ON inbox_note_detail (note_id);
@@ -0,0 +1,11 @@
-- Gitea #53: the PSN sync booked the whole history of a newly linked title on the sync day (24 h of a game last
-- played in 2025). A PSN row's day row more than one day after the day its titles were last played is no real
-- play: drop it. One day is kept: a sync after midnight booked the evening before on the next day.
-- Titles without last_played_at are left alone. Safe to run again.
DELETE FROM user_game_playtime_day p
USING user_game ug,
(SELECT user_id, appid, MAX(last_played_at) AS last_played_at FROM psn_title_play
GROUP BY user_id, appid) t
WHERE ug.user_id = p.user_id AND ug.appid = p.appid AND ug.source = 'PSN'
AND t.user_id = ug.user_id AND t.appid = ug.appid
AND p.day > (t.last_played_at AT TIME ZONE 'Europe/Berlin')::date + 1;
@@ -0,0 +1,28 @@
package core_service.configuration;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.assertj.core.api.Assertions.assertThat;
class DataSourceSettingsTest extends AbstractPersistenceTest {
@Autowired
private JdbcTemplate jdbc;
@Test
void coreConnection_ForcesGenericPlans() {
assertThat(jdbc.queryForObject("show plan_cache_mode", String.class)).isEqualTo("force_generic_plan");
}
@Test
void coreConnection_PreparesOnTheFirstUse() {
jdbc.queryForObject("select 1 where ? = 1", Integer.class, 1);
assertThat(jdbc.queryForObject(
"select count(*) from pg_prepared_statements where statement like 'select 1 where%'", Integer.class))
.isEqualTo(1);
}
}
@@ -68,14 +68,19 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test
void everyMapping_PointsToAnExistingAnswer() throws IOException {
List<Path> mappings;
try (Stream<Path> listed = Files.list(MAPPINGS)) {
mappings = listed.sorted().toList();
}
List<Path> mappings = sortedFiles(MAPPINGS);
assertThat(mappings).isNotEmpty();
for (Path mapping : mappings) {
JsonNode response = jsonMapper.readTree(mapping.toFile()).path("response");
JsonNode root = jsonMapper.readTree(mapping.toFile());
JsonNode response = root.path("response");
// A copied achievement mapping must ask for the appid in its own name.
String name = mapping.getFileName().toString();
if (name.matches("steam-(schema|player-achievements|global-percentages)-\\d+\\.json")) {
JsonNode query = root.path("request").path("queryParameters");
String asked = query.path(query.has("appid") ? "appid" : "gameid").path("equalTo").asString();
assertThat(name).as(mapping.toString()).isEqualTo(name.replaceAll("\\d+", asked));
}
// The PlayStation authorize answer is a redirect with no body.
assertThat(response.has("bodyFileName") || response.has("jsonBody") || response.path("headers").has("Location"))
.as(mapping.toString()).isTrue();
@@ -105,10 +110,7 @@ class LocalMocksParseTest extends AbstractUnitTest {
@Test
void everyRecordedAnswer_ParsesIntoItsResponseType() throws IOException, ReflectiveOperationException {
List<Path> files;
try (Stream<Path> listed = Files.list(FILES)) {
files = listed.sorted().toList();
}
List<Path> files = sortedFiles(FILES);
assertThat(files).isNotEmpty();
for (Path file : files) {
@@ -121,4 +123,30 @@ class LocalMocksParseTest extends AbstractUnitTest {
assertFilled(name, jsonMapper.readValue(file.toFile(), type));
}
}
// A copied stub with a typo in its appid is otherwise only a silent 400 in the sync log.
@Test
void everyAchievementStub_BelongsToAnOwnedGame() throws IOException {
List<String> owned = jsonMapper.readValue(FILES.resolve("steam-owned-games.json").toFile(), SteamOwnedGamesResponse.class)
.response().games().stream()
.map(game -> game.appid().toString())
.toList();
List<Path> schemas = sortedFiles(FILES).stream()
.filter(file -> file.getFileName().toString().matches("steam-schema-\\d+\\.json"))
.toList();
assertThat(schemas).isNotEmpty();
for (Path schema : schemas) {
String appid = schema.getFileName().toString().replaceAll("\\D", "");
assertThat(owned).as(schema.toString()).contains(appid);
assertThat(FILES.resolve("steam-player-achievements-" + appid + ".json")).exists();
assertThat(FILES.resolve("steam-global-percentages-" + appid + ".json")).exists();
}
}
private static List<Path> sortedFiles(Path dir) throws IOException {
try (Stream<Path> listed = Files.list(dir)) {
return listed.sorted().toList();
}
}
}
@@ -46,7 +46,8 @@ class ClientHeaderFilterTest extends AbstractMockedAdaptersTest {
.header("Access-Control-Request-Headers", "x-tbp-client"))
.andExpect(status().isOk())
.andExpect(header().string("Access-Control-Allow-Origin", FRONTEND))
.andExpect(header().string("Access-Control-Allow-Credentials", "true"));
.andExpect(header().string("Access-Control-Allow-Credentials", "true"))
.andExpect(header().string("Access-Control-Max-Age", "3600"));
mockMvc.perform(options("/api/library/games/220/backlog")
.header("Origin", FRONTEND)
@@ -14,6 +14,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilde
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@@ -62,6 +63,7 @@ class FollowControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from follow");
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow_digest");
jdbc.update("delete from public_user");
jdbc.update("delete from account_name");
@@ -82,7 +84,15 @@ class FollowControllerTest extends AbstractIntegrationTest {
}
private JsonNode list(long userId) throws Exception {
String body = mockMvc.perform(as(get("/api/follows"), userId)).andExpect(status().isOk())
return okJson(get("/api/follows"), userId);
}
private JsonNode players(long userId, String query) throws Exception {
return okJson(get("/api/follows/players").param("q", query), userId);
}
private JsonNode okJson(MockHttpServletRequestBuilder request, long userId) throws Exception {
String body = mockMvc.perform(as(request, userId)).andExpect(status().isOk())
.andReturn().getResponse().getContentAsString();
return JsonMapper.builder().build().readTree(body);
}
@@ -204,6 +214,95 @@ class FollowControllerTest extends AbstractIntegrationTest {
assertThat(json.toString()).doesNotContain("SecretThree", String.valueOf(LOBO), String.valueOf(SECRET));
Set<String> keys = new HashSet<>();
followers.get(0).propertyNames().forEach(keys::add);
assertThat(keys).containsExactlyInAnyOrder("id", "displayName");
assertThat(keys).containsExactlyInAnyOrder("id", "displayName", "lastActivityAt");
}
private void digestNote(long readerId, long subjectId, String kind, int minutesAgo) {
jdbc.update("insert into inbox_note (user_id, kind, subject_user_id, subject_name, created_at) "
+ "values (?, ?, ?, 'x', now() - make_interval(mins => ?))", readerId, kind, subjectId, minutesAgo);
}
@Test
void following_LastActivityIsTheNewestDigestNoteOfTheCallerAboutThatUser() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?), (?, ?), (?, ?)",
VIEWER, LOBO, VIEWER, OTHER, OTHER, LOBO);
digestNote(VIEWER, LOBO, "DIGEST", 60);
digestNote(VIEWER, LOBO, "DIGEST", 10);
digestNote(VIEWER, LOBO, "VISIBILITY", 1);
digestNote(OTHER, LOBO, "DIGEST", 0);
java.time.Instant newest = jdbc.queryForObject("select max(created_at) from inbox_note where user_id = ? and kind = 'DIGEST'",
java.sql.Timestamp.class, VIEWER).toInstant();
JsonNode following = list(VIEWER).get("following");
JsonNode lobo = following.get(0).get("displayName").asString().equals("Lobo") ? following.get(0) : following.get(1);
JsonNode other = lobo == following.get(0) ? following.get(1) : following.get(0);
assertThat(java.time.Instant.parse(lobo.get("lastActivityAt").asString())).isEqualTo(newest);
assertThat(other.get("lastActivityAt").isNull()).isTrue();
}
@Test
void followers_HaveNoLastActivity() throws Exception {
jdbc.update("insert into follow (user_id, followed_user_id) values (?, ?)", LOBO, VIEWER);
digestNote(VIEWER, LOBO, "DIGEST", 5);
assertThat(list(VIEWER).get("followers").get(0).get("lastActivityAt").isNull()).isTrue();
}
@Test
void players_FindsPublicNamesCaseInsensitiveAndNotPrivateBlockedDeleted() throws Exception {
JsonNode json = players(VIEWER, "ER");
List<String> names = new ArrayList<>();
json.get("results").forEach(player -> names.add(player.get("displayName").asString()));
assertThat(names).containsExactlyInAnyOrder("Viewer", "OtherSix");
assertThat(json.get("results").get(0).propertyNames()).containsExactly("displayName");
assertThat(players(VIEWER, "re").get("results")).isEmpty();
assertThat(players(VIEWER, "ed").get("results")).isEmpty();
}
@Test
void players_PrefixHitsComeFirst() throws Exception {
name(OTHER + 2, "Zolo");
jdbc.update("insert into public_user (user_id) values (?)", OTHER + 2);
JsonNode results = players(VIEWER, "lo").get("results");
assertThat(results.get(0).get("displayName").asString()).isEqualTo("Lobo");
assertThat(results.get(1).get("displayName").asString()).isEqualTo("Zolo");
}
@Test
void players_ShortQueryIsEmpty() throws Exception {
assertThat(players(VIEWER, "x").get("results")).isEmpty();
}
@Test
void players_EscapesLikeWildcards() throws Exception {
assertThat(players(VIEWER, "%").get("results")).isEmpty();
assertThat(players(VIEWER, "_o").get("results")).isEmpty();
}
@Test
void players_QueryLongerThan100_Is400() throws Exception {
mockMvc.perform(as(get("/api/follows/players").param("q", "x".repeat(101)), VIEWER))
.andExpect(status().isBadRequest());
}
@Test
void players_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/follows/players").param("q", "lo")).andExpect(status().isUnauthorized());
}
@Test
void players_31stSearchInAMinute_Is429() throws Exception {
long searcher = 950_001L;
for (int i = 0; i < 30; i++) {
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher)).andExpect(status().isOk());
}
mockMvc.perform(as(get("/api/follows/players").param("q", "lo"), searcher))
.andExpect(status().isTooManyRequests())
.andExpect(jsonPath("$.code").value("TOO_MANY_SEARCHES"));
}
}
@@ -0,0 +1,65 @@
package core_service.modules.follow.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class DigestItemsTest extends AbstractUnitTest {
private static DigestItems of(DigestItem... items) {
return new DigestItems(List.of(items));
}
private static final DigestItems ALL = of(DigestItem.newGame(1, "A"), DigestItem.achievement(1, "A", "Win"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.wishlist(3, "C"));
@Test
void collapseStatus_FirstFromToLastTo() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.status(1, "A", "BACKLOG", "FINISHED"));
}
@Test
void collapseStatus_DropsAGameThatEndsWhereItStarted() {
DigestItems items = of(DigestItem.status(1, "A", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "BACKLOG"));
assertThat(items.collapseStatus().items()).isEmpty();
}
@Test
void collapseStatus_KeepsGamesApartAndOtherKindsAndOrder() {
DigestItems items = of(DigestItem.newGame(9, "N"), DigestItem.status(1, "A", "BACKLOG", "PLAYING"),
DigestItem.status(2, "B", "BACKLOG", "PLAYING"), DigestItem.status(1, "A", "PLAYING", "FINISHED"));
assertThat(items.collapseStatus().items()).containsExactly(DigestItem.newGame(9, "N"),
DigestItem.status(1, "A", "BACKLOG", "FINISHED"), DigestItem.status(2, "B", "BACKLOG", "PLAYING"));
}
@Test
void visibleOn_NullKeepsAll() {
assertThat(ALL.visibleOn(null)).isEqualTo(ALL);
}
@Test
void visibleOn_StatusSectionDropsNewGamesAndAchievementsAndKeepsWishlist() {
assertThat(ALL.visibleOn(Set.of("STATUS")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.STATUS, DigestItem.WISHLIST);
}
@Test
void visibleOn_SummaryKeepsNewGamesAndAchievementsButNotStatus() {
assertThat(ALL.visibleOn(Set.of("SUMMARY")).items()).extracting(DigestItem::kind)
.containsExactly(DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT, DigestItem.WISHLIST);
}
@Test
void plus_KeepsReceiverFirst() {
assertThat(of(DigestItem.wishlist(1, "A")).plus(of(DigestItem.wishlist(2, "B"))).items())
.extracting(DigestItem::appid).containsExactly(1L, 2L);
}
}
@@ -1,6 +1,8 @@
package core_service.modules.follow.repo;
import core_service.modules.follow.domain.Digest;
import core_service.modules.follow.domain.DigestItem;
import core_service.modules.follow.domain.DigestItems;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -8,6 +10,8 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Import(FollowDigestRepository.class)
@@ -27,7 +31,7 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
void seed() {
jdbc.update("insert into sync_result (user_id, sync_started_at, started_by, imported_games) values (?, now(), 'AUTO', 0)", USER);
for (long appid : new long[]{LISTED, REMOVED}) {
jdbc.update("insert into steam_game (appid, name) values (?, 'Game')", appid);
jdbc.update("insert into steam_game (appid, name) values (?, ?)", appid, "Game " + appid);
jdbc.update("insert into user_game (user_id, appid, status, source, platform, status_set_by_user) "
+ "values (?, ?, 'BACKLOG', 'MANUAL', 'PC', true)", USER, appid);
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, 'ADDED', ?, null)", USER, appid);
@@ -49,4 +53,81 @@ class FollowDigestRepositoryTest extends AbstractPersistenceTest {
assertThat(digest.newGames()).isEqualTo(1);
}
private void unlock(long appid, String apiName, String displayName, int daysAgo) {
jdbc.update("insert into game_achievement_schema (appid, api_name, display_name) values (?, ?, ?)", appid, apiName, displayName);
jdbc.update("insert into user_game_achievement (user_id, appid, api_name, unlocked, unlocked_at) "
+ "values (?, ?, ?, true, now() - make_interval(days => ?))", USER, appid, apiName, daysAgo);
}
@Test
void findSyncItems_SkipsRemovedGamesAndNamesTheGame() {
DigestItems items = repository.findSyncItems(USER);
assertThat(items.items()).contains(DigestItem.newGame(LISTED, "Game 10"));
assertThat(items.items()).noneMatch(item -> item.appid() == REMOVED);
}
@Test
void findSyncItems_TakesTheAmountNewestUnlocks() {
unlock(LISTED, "a", "Oldest", 40);
unlock(LISTED, "b", "Third", 30);
unlock(LISTED, "c", "Second", 20);
unlock(LISTED, "d", "Newest", 10);
unlock(REMOVED, "e", "Removed one", 1);
List<DigestItem> achievements = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).toList();
assertThat(achievements).containsExactly(DigestItem.achievement(LISTED, "Game 10", "Newest"),
DigestItem.achievement(LISTED, "Game 10", "Second"), DigestItem.achievement(LISTED, "Game 10", "Third"));
}
@Test
void findSyncItems_PsnTrophiesCompeteWithSteamUnlocksOfTheSameGame() {
unlock(LISTED, "a", "Old steam", 40);
jdbc.update("insert into psn_trophy_set (np_communication_id, service_name, platform, name, defined_bronze, "
+ "defined_silver, defined_gold, defined_platinum) values ('NPWR1', 'trophy', 'PS4', 'T', 1, 0, 0, 0)");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 1, 'BRONZE', 'Trophy one')");
jdbc.update("insert into psn_trophy (np_communication_id, trophy_id, type, name) values ('NPWR1', 2, 'BRONZE', null)");
jdbc.update("insert into user_psn_trophy_set (user_id, np_communication_id, appid, earned_total, platinum_earned) "
+ "values (?, 'NPWR1', ?, 2, false)", USER, LISTED);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 1, now() - interval '1 day')", USER);
jdbc.update("insert into user_psn_trophy (user_id, np_communication_id, trophy_id, earned_at) values (?, 'NPWR1', 2, now() - interval '2 days')", USER);
List<String> names = repository.findSyncItems(USER).items().stream()
.filter(item -> DigestItem.ACHIEVEMENT.equals(item.kind())).map(DigestItem::itemName).toList();
assertThat(names).containsExactly("Trophy one", "Trophy 2", "Old steam");
}
@Test
void findWishlistItems_UsesTheWishlistName() {
jdbc.update("insert into wishlist_game (user_id, appid, name) values (?, 777, 'Wished')", USER);
jdbc.update("insert into sync_change (user_id, kind, appid) values (?, 'WISHLIST', 777)", USER);
assertThat(repository.findWishlistItems(USER, 0, Long.MAX_VALUE).items())
.containsExactly(DigestItem.wishlist(777, "Wished"));
}
@Test
void findHandItems_NoOldStatusIsANewGame() {
assertThat(repository.findHandItems(USER, 0, Long.MAX_VALUE, false).items())
.containsExactly(DigestItem.newGame(LISTED, "Game 10"));
}
@Test
void insertDigestNotesAndItems_ReturnIdsAndWriteRowsPerNote() {
jdbc.update("insert into public_user (user_id) values (?)", USER);
jdbc.update("insert into follow (user_id, followed_user_id) values (2, ?), (3, ?)", USER, USER);
List<Long> ids = repository.insertDigestNotes(USER, new Digest(1, 0, 0, 0));
repository.insertDigestItems(ids, new DigestItems(List.of(DigestItem.newGame(LISTED, "Game 10"),
DigestItem.wishlist(5, "W"))));
assertThat(ids).hasSize(2);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isEqualTo(4);
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail where note_id = ?", Integer.class, ids.get(0)))
.isEqualTo(2);
}
}
@@ -79,6 +79,13 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
jdbc.update("insert into sync_change (user_id, kind, appid, amount) values (?, ?, ?, ?)", FOLLOWED, kind, appid, amount);
}
private void moved(long appid) {
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, appid);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'BACKLOG', 'PLAYING', false)",
FOLLOWED, appid);
}
private void added(int count) {
games(count);
sync();
@@ -131,6 +138,87 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select subject_name from inbox_note limit 1", String.class)).isEqualTo("Followed");
}
private List<Map<String, Object>> details(long reader) {
return jdbc.queryForList("select d.kind, d.game_name, d.item_name, d.from_status, d.to_status from inbox_note_detail d "
+ "join inbox_note n on n.id = d.note_id where n.user_id = ? order by d.id", reader);
}
@Test
void syncBatch_WritesTheLinesBehindTheCountsForEachFollower() {
added(2);
moved(FIRST_APPID + 1);
change("WISHLIST", 777_001L, null);
event();
for (long reader : List.of(FOLLOWER_ONE, FOLLOWER_TWO)) {
assertThat(details(reader)).extracting(row -> row.get("kind"))
.containsExactlyInAnyOrder("NEW_GAME", "NEW_GAME", "STATUS", "WISHLIST");
assertThat(details(reader)).anySatisfy(row -> assertThat(row).containsEntry("game_name", "Game 1"));
}
}
@Test
void handChangesThereAndBack_WriteNoStatusLineButTheCountStays() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("update user_game_status_change set changed_at = now() - interval '30 minutes' where user_id = ?", FOLLOWED);
service.digestHandChanges();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).doesNotContain("STATUS");
}
@Test
void handMoveBeforeSyncMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'PLAYING', 'FINISHED')",
FOLLOWED, 920_001L);
jdbc.update("insert into user_game_status_change (user_id, appid, from_status, to_status, by_user) values (?, ?, 'PLAYING', 'FINISHED', false)",
FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void syncMoveBeforeHandMoveGivesOneLineFromFirstToLast() {
handGame(920_001L, 30);
jdbc.update("delete from user_game_status_change where user_id = ? and appid = ?", FOLLOWED, 920_001L);
sync();
jdbc.update("update user_game set status = 'PLAYING', status_set_by_user = false where user_id = ? and appid = ?", FOLLOWED, 920_001L);
jdbc.update("insert into sync_change (user_id, kind, appid, from_status, to_status) values (?, 'STATUS', ?, 'BACKLOG', 'PLAYING')",
FOLLOWED, 920_001L);
jdbc.update("update user_game set status = 'FINISHED', status_set_by_user = true where user_id = ? and appid = ?", FOLLOWED, 920_001L);
event();
assertThat(notes().get(0)).containsEntry("status_changes", 2);
assertThat(details(FOLLOWER_ONE)).filteredOn(row -> "STATUS".equals(row.get("kind")))
.extracting(row -> row.get("from_status"), row -> row.get("to_status"))
.containsExactly(org.assertj.core.groups.Tuple.tuple("BACKLOG", "FINISHED"));
}
@Test
void statusSectionOnly_WritesNoNewGameOrAchievementLines() {
added(2);
change("ACHIEVEMENTS", FIRST_APPID + 1, 3);
moved(FIRST_APPID + 1);
sections("STATUS");
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind")).containsExactly("STATUS");
}
@Test
void noActivity_WritesNoNoteButMovesTheCursor() {
sync();
@@ -351,4 +439,20 @@ class FollowDigestServiceTest extends AbstractIntegrationTest {
assertThat(jdbc.queryForObject("select count(*) from inbox_note where achievements = 7 and new_games = 2",
Integer.class)).isEqualTo(2);
}
@Test
void handJobBeforeSync_LeavesTheSyncStatusRowsForTheSyncDigest() {
handGame(920_001L, 30);
added(1);
moved(FIRST_APPID + 1);
service.digestHandChanges();
event();
assertThat(details(FOLLOWER_ONE)).extracting(row -> row.get("kind"), row -> row.get("game_name"))
.containsExactlyInAnyOrder(org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Hand"),
org.assertj.core.groups.Tuple.tuple("NEW_GAME", "Game 1"),
org.assertj.core.groups.Tuple.tuple("STATUS", "Game 1"));
assertThat(notes()).extracting(note -> note.get("status_changes")).containsOnly(0, 1);
}
}
@@ -0,0 +1,19 @@
package core_service.modules.follow.service;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
class PlayerSearchLimiterTest extends AbstractUnitTest {
@Test
void allow_RefusesTheSearchAfterTheLimitAndCountsEachUserOnItsOwn() {
PlayerSearchLimiter limiter = new PlayerSearchLimiter(2);
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isTrue();
assertThat(limiter.allow(1)).isFalse();
assertThat(limiter.allow(2)).isTrue();
}
}
@@ -35,6 +35,25 @@ class InboxControllerTest extends AbstractIntegrationTest {
@AfterEach
void cleanUp() {
jdbc.update("delete from inbox_note");
jdbc.update("delete from follow where user_id in (?, ?)", READER, OTHER);
jdbc.update("delete from public_user where user_id = 7");
jdbc.update("delete from account_name where user_id = 7");
jdbc.update("delete from user_game where user_id = 7");
jdbc.update("delete from steam_game where appid = 501");
}
/** A note of READER with lines, the subject (7) public and followed. */
private long noteWithDetails() {
note(READER, "Subject", 1, 2);
long id = jdbc.queryForObject("select max(id) from inbox_note", Long.class);
jdbc.update("insert into follow (user_id, followed_user_id) values (?, 7)", READER);
jdbc.update("insert into public_user (user_id) values (7)");
jdbc.update("insert into account_name (user_id, display_name) values (7, 'SubjectNow')");
jdbc.update("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) values "
+ "(?, 'NEW_GAME', 501, 'Alpha', null, null, null), "
+ "(?, 'ACHIEVEMENT', 501, 'Alpha', 'Win', null, null), "
+ "(?, 'STATUS', 502, 'Beta', null, 'BACKLOG', 'PLAYING')", id, id, id);
return id;
}
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
@@ -65,6 +84,7 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].kind").value("DIGEST"))
.andExpect(jsonPath("$.results[0].newGames").value(2))
.andExpect(jsonPath("$.results[0].read").value(false))
.andExpect(jsonPath("$.results[0].profileName").doesNotExist())
.andExpect(jsonPath("$.results[1].displayName").value("Older"))
.andExpect(jsonPath("$.unread").value(2))
.andReturn().getResponse().getContentAsString();
@@ -95,4 +115,65 @@ class InboxControllerTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.results[0].read").value(true));
mockMvc.perform(as(get("/api/inbox"), OTHER)).andExpect(jsonPath("$.unread").value(1));
}
@Test
void details_OfAnOwnNoteListsTheLinesWithoutIdentifiers() throws Exception {
long id = noteWithDetails();
String body = mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(3))
.andExpect(jsonPath("$.results[0].kind").value("NEW_GAME"))
.andExpect(jsonPath("$.results[0].appid").value(501))
.andExpect(jsonPath("$.results[2].appid").value(502))
.andExpect(jsonPath("$.results[1].itemName").value("Win"))
.andExpect(jsonPath("$.results[2].fromStatus").value("BACKLOG"))
.andReturn().getResponse().getContentAsString();
assertThat(body).doesNotContain("subject", "userId");
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(true))
.andExpect(jsonPath("$.results[0].profileName").value("SubjectNow"));
}
@Test
void details_OfAnotherReadersNoteIs404() throws Exception {
long id = noteWithDetails();
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), OTHER)).andExpect(status().isNotFound())
.andExpect(jsonPath("$.error").doesNotExist());
}
@Test
void details_AfterUnfollowIs404AndTheListShowsNoDetails() throws Exception {
long id = noteWithDetails();
jdbc.update("delete from follow where user_id = ?", READER);
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isNotFound());
mockMvc.perform(as(get("/api/inbox"), READER)).andExpect(jsonPath("$.results[0].details").value(false));
}
@Test
void details_OnlyKindsOfTheSectionsThatAreStillPublic() throws Exception {
long id = noteWithDetails();
jdbc.update("update public_user set sections = '{STATUS}' where user_id = 7");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].kind").value("STATUS"));
}
@Test
void details_LeaveOutAGameTheSubjectHidesNow() throws Exception {
long id = noteWithDetails();
jdbc.update("insert into steam_game (appid, name) values (501, 'Alpha') on conflict do nothing");
jdbc.update("insert into user_game (user_id, appid, status, hidden_at) values (7, 501, 'BACKLOG', now())");
mockMvc.perform(as(get("/api/inbox/" + id + "/details"), READER)).andExpect(status().isOk())
.andExpect(jsonPath("$.results.length()").value(1))
.andExpect(jsonPath("$.results[0].gameName").value("Beta"));
}
@Test
void details_WithoutLoginIs401() throws Exception {
mockMvc.perform(get("/api/inbox/1/details")).andExpect(status().isUnauthorized());
}
}
@@ -0,0 +1,44 @@
package core_service.modules.inbox.domain;
import core_service.shared.test.AbstractUnitTest;
import org.junit.jupiter.api.Test;
import java.util.Set;
import static org.assertj.core.api.Assertions.assertThat;
class NoteDetailKindTest extends AbstractUnitTest {
@Test
void visibleOn_NullSectionsShowsEverything() {
for (NoteDetailKind kind : NoteDetailKind.values()) {
assertThat(kind.visibleOn(null)).as(kind.name()).isTrue();
}
}
@Test
void visibleOn_SummaryShowsGamesAndAchievementsOnly() {
Set<String> sections = Set.of("SUMMARY");
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).isFalse();
assertThat(NoteDetailKind.WISHLIST.visibleOn(sections)).isTrue();
}
@Test
void visibleOn_EachStatusSectionShowsStatusButNotGames() {
for (String section : new String[]{"STATUS", "PLAYING", "FINISHED"}) {
Set<String> sections = Set.of(section);
assertThat(NoteDetailKind.STATUS.visibleOn(sections)).as(section).isTrue();
assertThat(NoteDetailKind.NEW_GAME.visibleOn(sections)).as(section).isFalse();
assertThat(NoteDetailKind.ACHIEVEMENT.visibleOn(sections)).as(section).isFalse();
}
}
@Test
void visibleOn_OnlyStatsLeavesTheWishlist() {
assertThat(NoteDetailKind.WISHLIST.visibleOn(Set.of("STATS"))).isTrue();
assertThat(NoteDetailKind.STATUS.visibleOn(Set.of("STATS"))).isFalse();
}
}
@@ -1,5 +1,6 @@
package core_service.modules.inbox.repo;
import core_service.modules.inbox.domain.NoteDetail;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
@@ -43,4 +44,64 @@ class InboxRepositoryTest extends AbstractPersistenceTest {
assertThat(deleted).isEqualTo(2);
assertThat(ids()).containsExactly(3L, 4L);
}
@Test
void deleteBefore_AlsoRemovesTheDetailRows() {
jdbc.update("delete from inbox_note");
note(1, 91, true);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
repository.deleteBefore(Instant.now().minus(Duration.ofDays(90)));
assertThat(jdbc.queryForObject("select count(*) from inbox_note_detail", Integer.class)).isZero();
}
@Test
void findNewest_DetailsNeedRowsAndAFollowAndAPublicSubject() {
jdbc.update("delete from inbox_note");
note(1, 1, false);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into follow (user_id, followed_user_id) values (1, 2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).details()).isTrue();
jdbc.update("delete from inbox_note_detail");
assertThat(repository.findNewest(1, 10).get(0).details()).isFalse();
}
@Test
void findNewest_ProfileNameIsTheCurrentNameOfAPublicSubjectOnly() {
jdbc.update("delete from inbox_note");
jdbc.update("delete from account_name where user_id = 2");
jdbc.update("delete from public_user where user_id = 2");
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("delete from deleted_user where user_id = 2");
note(1, 1, false);
jdbc.update("insert into account_name (user_id, display_name) values (2, 'Renamed')");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("insert into public_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isEqualTo("Renamed");
jdbc.update("insert into blocked_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from blocked_user where user_id = 2");
jdbc.update("insert into deleted_user (user_id) values (2)");
assertThat(repository.findNewest(1, 10).get(0).profileName()).isNull();
jdbc.update("delete from deleted_user where user_id = 2");
}
@Test
void findDetails_CarriesTheGameKey() {
jdbc.update("delete from inbox_note");
note(1, 1, false);
jdbc.update("insert into inbox_note_detail (note_id, kind, appid) values (1, 'NEW_GAME', 5)");
assertThat(repository.findDetails(1, 2)).extracting(NoteDetail::appid).containsExactly(5L);
}
}
@@ -0,0 +1,91 @@
package core_service.modules.library.api;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.ResultActions;
import static org.hamcrest.Matchers.contains;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class GamePreviewControllerTest extends AbstractIntegrationTest {
private static final long CALLER = 6201L;
private static final long KEPT = -1_960_001L;
private static final long MERGED = -1_960_002L;
private static final long UNKNOWN = -1_960_003L;
@Autowired
private MockMvc mockMvc;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
@AfterEach
void cleanUp() {
jdbc.update("delete from game_alias where from_appid = ?", MERGED);
jdbc.update("delete from game_time_to_beat where appid = ?", KEPT);
jdbc.update("delete from game_mood where appid = ?", KEPT);
jdbc.update("delete from steam_game where appid in (?, ?)", KEPT, MERGED);
}
private void seed() {
jdbc.update("insert into steam_game (appid, name, image_url) values (?, 'Hades', 'http://img/h.png')", KEPT);
jdbc.update("insert into game_mood (appid, moods, genres, checked_at) values (?, '{}', '{Roguelike,Action}', now())", KEPT);
jdbc.update("insert into game_time_to_beat (appid, normally_minutes, completely_minutes, fetched_at) "
+ "values (?, 600, 1200, now())", KEPT);
}
private ResultActions preview(long appid) throws Exception {
return mockMvc.perform(get("/api/library/games/" + appid + "/preview")
.header("Authorization", "Bearer " + ContractTokens.loginToken(CALLER)));
}
@Test
void preview_OfAGameTheCallerDoesNotOwn_HasCatalogDataOnly() throws Exception {
seed();
preview(KEPT).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"))
.andExpect(jsonPath("$.imageUrl").value("http://img/h.png"))
.andExpect(jsonPath("$.genres", contains("Roguelike", "Action")))
.andExpect(jsonPath("$.timeToBeat.normallyMinutes").value(600))
.andExpect(jsonPath("$.timeToBeat.completelyMinutes").value(1200))
.andExpect(jsonPath("$.status").doesNotExist());
}
@Test
void preview_OfAMergedKey_AnswersWithTheKeptKey() throws Exception {
seed();
jdbc.update("insert into steam_game (appid, name) values (?, 'Old Hades')", MERGED);
jdbc.update("insert into game_alias (from_appid, to_appid) values (?, ?)", MERGED, KEPT);
preview(MERGED).andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(KEPT))
.andExpect(jsonPath("$.name").value("Hades"));
}
@Test
void preview_OfAnUnknownKey_Is404() throws Exception {
preview(UNKNOWN).andExpect(status().isNotFound())
.andExpect(jsonPath("$.code").value("GAME_NOT_FOUND"));
}
@Test
void preview_WithoutLogin_Is401() throws Exception {
mockMvc.perform(get("/api/library/games/" + KEPT + "/preview")).andExpect(status().isUnauthorized());
}
}
@@ -91,6 +91,16 @@ class LibraryControllerActivityTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.recentGames[1].imageUrl").doesNotExist());
}
@Test
void activity_ADayCountsAtMost24Hours() throws Exception {
playtimeDayRepository.addMinutes(USER_ID, 220L, today, 1440);
playtimeDayRepository.addMinutes(USER_ID, 620L, today, 1440);
mockMvc.perform(get("/api/library/activity").param("days", "7"))
.andExpect(jsonPath("$.days[6].minutes").value(1440))
.andExpect(jsonPath("$.totalMinutes").value(1440));
}
@Test
void activity_RecentGamesAreOneEntryPerSetWithTheSumOfItsEditions() throws Exception {
jdbc.update("update user_game set playtime_2weeks = 50 where user_id = ? and appid = 700", USER_ID);
@@ -0,0 +1,130 @@
package core_service.modules.library.api;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameRepository;
import core_service.shared.test.AbstractIntegrationTest;
import core_service.shared.test.ContractTokens;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.test.context.ActiveProfiles;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder;
import tools.jackson.databind.JsonNode;
import tools.jackson.databind.json.JsonMapper;
import java.time.Instant;
import java.util.List;
import java.util.stream.Stream;
import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@AutoConfigureMockMvc
@ActiveProfiles(value = "prod", inheritProfiles = false)
class LibraryControllerDashboardGamesTest extends AbstractIntegrationTest {
private static final long USER = 931_101L;
private static final long OTHER = 931_102L;
private static final JsonMapper MAPPER = JsonMapper.builder().build();
@Autowired
private MockMvc mockMvc;
@Autowired
private SteamGameRepository steamGameRepository;
@Autowired
private UserGameRepository userGameRepository;
@Autowired
private JdbcTemplate jdbc;
@BeforeEach
void seed() {
userGameRepository.deleteAll();
// 25 games, playtime falls with the appid; 1, 3, 5 and 20 play (20 is outside the top 10)
for (long appid = 1; appid <= 25; appid++) {
steamGameRepository.save(new SteamGame(appid, "Game " + appid, null, Instant.now()));
userGameRepository.save(new UserGame(USER, appid, (int) (1000 - appid * 10), Instant.now(), Instant.now()));
}
jdbc.update("update user_game set status = 'BACKLOG' where user_id = ?", USER);
jdbc.update("update user_game set status = 'PLAYING' where user_id = ? and appid in (1, 3, 5, 20)", USER);
steamGameRepository.save(new SteamGame(900L, "Other", null, Instant.now()));
userGameRepository.save(new UserGame(OTHER, 900L, 99_999, Instant.now(), Instant.now()));
jdbc.update("update user_game set status = 'PLAYING' where user_id = ?", OTHER);
}
private static MockHttpServletRequestBuilder as(MockHttpServletRequestBuilder request, long userId) {
return request.header("Authorization", "Bearer " + ContractTokens.loginToken(userId));
}
private JsonNode json(MockHttpServletRequestBuilder request) throws Exception {
return MAPPER.readTree(mockMvc.perform(as(request, USER)).andExpect(status().isOk()).andReturn()
.getResponse().getContentAsString());
}
private static List<Long> appids(JsonNode games) {
return Stream.iterate(0, i -> i + 1).limit(games.size()).map(i -> games.get(i).get("appid").asLong()).toList();
}
@Test
void getDashboardGames_ReturnsBothTilesInTheOrderOfGames() throws Exception {
JsonNode dashboard = json(get("/api/library/dashboard-games").param("top", "3").param("playing", "3"));
JsonNode topGames = json(get("/api/library/games").param("size", "20").param("sort", "playtimeMinutes,desc"));
JsonNode playingGames = json(get("/api/library/games").param("size", "20").param("status", "PLAYING"));
assertThat(appids(dashboard.get("top"))).containsExactly(1L, 2L, 3L)
.isEqualTo(appids(topGames.get("content")).subList(0, 3));
assertThat(appids(dashboard.get("playing"))).containsExactly(1L, 3L, 5L)
.isEqualTo(appids(playingGames.get("content")).subList(0, 3));
assertThat(dashboard.get("top").get(0)).isEqualTo(topGames.get("content").get(0));
assertThat(dashboard.get("playing").get(2)).isEqualTo(playingGames.get("content").get(2));
}
@Test
void getDashboardGames_ClampsTheLimits() throws Exception {
JsonNode low = json(get("/api/library/dashboard-games").param("top", "0").param("playing", "-4"));
JsonNode high = json(get("/api/library/dashboard-games").param("top", "500").param("playing", "500"));
assertThat(low.get("top")).hasSize(1);
assertThat(low.get("playing")).hasSize(1);
assertThat(high.get("top")).hasSize(20);
assertThat(high.get("playing")).hasSize(4);
}
@Test
void getDashboardGames_DefaultsAreTenAndEight() throws Exception {
JsonNode dashboard = json(get("/api/library/dashboard-games"));
assertThat(dashboard.get("top")).hasSize(10);
assertThat(dashboard.get("playing")).hasSize(4);
}
@Test
void getDashboardGames_OnlyTheCallersGames() throws Exception {
JsonNode dashboard = json(get("/api/library/dashboard-games"));
assertThat(appids(dashboard.get("top"))).doesNotContain(900L);
assertThat(appids(dashboard.get("playing"))).doesNotContain(900L);
}
@Test
void getDashboardGames_WithoutLogin_401() throws Exception {
mockMvc.perform(get("/api/library/dashboard-games")).andExpect(status().isUnauthorized());
}
@Test
void getGameDetail_RefreshFalse_HasTheStaleFlag() throws Exception {
mockMvc.perform(as(get("/api/library/games/1").param("refresh", "false"), USER))
.andExpect(status().isOk())
.andExpect(jsonPath("$.appid").value(1))
.andExpect(jsonPath("$.achievementsStale").value(true));
}
}
Loaded 100 of 117 files, more files were not shown because too many files have changed in this diff. Show more