Public Access
Compare commits
32
Commits
0ddb345a41
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11fae8e31 | ||
|
|
c42526bc16 | ||
|
|
4fbf572f32 | ||
|
|
6f2d7b4885 | ||
|
|
d3c9c7558c | ||
|
|
ec66bd5ead | ||
|
|
54946ef0eb | ||
|
|
dba4fedfa1 | ||
|
|
cdfbb4a863 | ||
|
|
c6bfea1976 | ||
|
|
03469f9eb5 | ||
|
|
b1aaec0615 | ||
|
|
9efed0ab1d | ||
|
|
bb195a1f79 | ||
|
|
0f56f5bb0f | ||
|
|
1de8acef2e | ||
|
|
59d6b3394d | ||
|
|
1a9a4e912c | ||
|
|
6215bb0dec | ||
|
|
8f884040d8 | ||
|
|
0550f3fe9c | ||
|
|
e18b799501 | ||
|
|
4d49b0f023 | ||
|
|
696e06e7fe | ||
|
|
b24fd91e88 | ||
|
|
7c4b475321 | ||
|
|
4ed8a341af | ||
|
|
1ff1e061c7 | ||
|
|
413381d404 | ||
|
|
82d3b59165 | ||
|
|
8e4c382563 | ||
|
|
6cd92f3e83 |
No files matched your search
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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,27 +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 (and
|
||||
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns
|
||||
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
|
||||
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
|
||||
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
|
||||
name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns
|
||||
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
|
||||
all users, with up to 3 requests per game and 1 request per second; a result is kept 30 days.
|
||||
all users, with up to 3 requests per game and 1 request every 2 seconds; a result is kept 30 days. A rate limit answer
|
||||
(429 or 420) pauses the queue (its Retry-After, else 1 min doubling up to 30 min) and asks the same game again; the
|
||||
queue is never dropped for it. Migration V47 asks
|
||||
every stored match again once at boot, behind user requests; links set by an auditor are never touched.
|
||||
- **Achievements**: after every library sync a background job fetches the achievement schema of each owned game
|
||||
and the user's unlocks of each game whose playtime changed. One Steam call per second, so user requests keep half
|
||||
of the rate limit; it stops after 3 Steam errors in a row or when Steam hides the achievements. A Steam error never
|
||||
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
|
||||
games closest to 100 %.
|
||||
- **Playtime history**: one row per game and day, for the activity view and the "most played in the last two weeks"
|
||||
dashboard banner.
|
||||
- **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
|
||||
activity view and the "most played in the last two weeks" dashboard banner.
|
||||
- **Finished from achievements or playtime**: `EndingAchievement` finds the one achievement of a game that means
|
||||
"story end" (English text rule, Steam's global unlock rate between 1 and 80 %, the easiest variant wins, more than
|
||||
3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered
|
||||
@@ -86,6 +109,27 @@ To Be Played is split into three repositories:
|
||||
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
|
||||
mark as endless (sandbox, survival, 4X, simulator, MMO). "Not yet" hides a hint per game; a dismissed playtime hint
|
||||
still lets the ending hint through.
|
||||
- **Question queue** (Gitea #32): `GET /api/library/questions` lists one open question per game: Start it? (BACKLOG with
|
||||
playtime), Finished? (BACKLOG or PLAYING, playtime at least IGDB's normal time to beat, else 10 h; never for
|
||||
IGDB-endless games), Dropped? (PLAYING, no Steam playtime in the last 2 weeks and no sign of play for 90 days) and
|
||||
Rate it? (FINISHED or COMPLETED without a rating). The order is the priority. `OpenQuestion.of` is a pure rule over
|
||||
`QuestionFacts`, which one SQL query loads for the whole library (`counted_user_game`, so a linked set is one card).
|
||||
Every rule starts from the status, so a game the sync already finished or started drops out by itself. A "No" is
|
||||
stored in `user_game_question` (V41): Start it? and Finished? come back after 5 more hours of playtime, Dropped?
|
||||
after another 90 idle days, a Rate it? "Skip" never. `POST /api/library/questions/{appid}`
|
||||
(`{question, yes, rating?}`) checks that the question is still open (`409 QUESTION_GONE`, `404 GAME_NOT_FOUND`,
|
||||
`400 INVALID_ANSWER` for a yes to RATE without a rating) and then calls `BacklogService`, the same write path as the
|
||||
game page. `GET /api/library/questions/count` uses the same code as the list, so the badge equals the cards.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Facts["counted_user_game + playtime days + status log + time to beat + user_game_question"] --> Rule["OpenQuestion.of"]
|
||||
Rule --> Card["question card"]
|
||||
Answer["answer"] --> Backlog["BacklogService"] --> UserGame["user_game"] --> Trigger["V14 trigger: status log"] --> Settle["RewardService.settle"]
|
||||
Answer -->|"Frontend: GET /api/rewards/games/{appid}"| Settle
|
||||
Sync["library sync"] --> LibraryActivityEvent --> Settle
|
||||
Answer -->|no| Snooze["user_game_question"]
|
||||
```
|
||||
- **Hide games**: a user can hide a game, Steam or added by hand (e.g. a playtest or a tool). It leaves every list and number: library,
|
||||
picker, Pile of Shame, activity, achievement summary, public profile and landing page stats. The sync still
|
||||
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
|
||||
@@ -97,8 +141,15 @@ To Be Played is split into three repositories:
|
||||
status history); games and playtime per platform (`PLATFORMS`: Steam games as "Steam", manual games by platform, trimmed and ignoring case, the most used spelling is the label); the Pile of Shame trend (90 days of `backlog_snapshot`, plus "this month: added / done", where the
|
||||
first sync of a Steam account is no "added", see `user_steam_sync.history_since`). Every number counts only listed games (still owned, not hidden: the view
|
||||
`listed_user_game`, V24). A linked set (see "Editions of one game") counts once: its most advanced edition (status, then
|
||||
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Playtime
|
||||
sums, most played, genres, moods and platforms still use every listed game.
|
||||
playtime, then the older `first_seen_at`; `counted_user_game`, V28), so the set is on the pile only when every owned edition is `BACKLOG`. Most played, most loyal,
|
||||
genres, moods, perfect games and the longest day list one entry per set, with the playtime of all its editions
|
||||
(`user_game_entry_member`, V43; its list shows that sum as `minutes`). Every playtime number (most played, genres,
|
||||
moods, the total playtime and moon trips, the drill-down lists) uses the set total, the view
|
||||
`user_game_entry_playtime` (V45), the same as the library card, sort and public profile. So do "played", "never
|
||||
started" and "under one hour": a set counts once, judged by its set total (also the dashboard "never played" and the
|
||||
oldest unplayed game) and the trend's never played (`backlog_snapshot`); rows written before #43 keep the old value,
|
||||
they cannot be rebuilt.
|
||||
Platforms stay per edition: a Steam + PlayStation set counts on both platforms.
|
||||
- **Drill-down**: every countable number opens the games behind it. A `StatKey` (library), `CommunityKey` (landing page)
|
||||
or a Pile Points track/bonus row names a number; its SQL "game set" is the same text the number counts, so the
|
||||
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
|
||||
@@ -109,23 +160,30 @@ To Be Played is split into three repositories:
|
||||
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
|
||||
changes are not known.
|
||||
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
|
||||
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet` and
|
||||
(`reward_ledger`, V26; the balance is the sum). No code path pays: a settle (on `GET /api/rewards/wallet`, on `GET /api/rewards/games/{appid}`, after every sync (`LibraryActivityEvent`, own transaction, a failure only logs) and
|
||||
nightly at 02:30, `app.rewards.settle-cron`) derives rows from the status log, playtime days, achievements and pile
|
||||
snapshots, once per user, kind and game (`ref`). Rules and numbers: `RewardRules`; a Steam finish needs playtime and one
|
||||
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish; old history pays nothing). The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
||||
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
|
||||
proof of fresh play (ending achievement or playtime around the finish, or half of the IGDB time played since the finish). Without proof a Steam finish pays 10 early points (`FINISHED_EARLY`), only for finishes on or after
|
||||
`app.rewards.early-since` (older unproven finishes pay only with proof); when proof arrives later, `FINISHED_TOPUP` pays the rest up to the full amount, once per game or set. PlayStation days never prove: a game linked to a PlayStation title pays by its own rules, only
|
||||
PlayStation rows pay nothing. The month bonus needs a finished game in that month. Games added by hand pay 15 for
|
||||
a finish. Manual finishes and early finishes share the cap of 3 per ISO week. `COMPLETED` needs a `FINISHED`, `FINISHED_TOPUP` or `FINISHED_MANUAL` row on the game or its set, so a manual edition's finish opens it for the Steam edition, but only when the Steam game's last achievement
|
||||
unlock (100 % reached) is on or after `app.rewards.early-since` (else `COMPLETION_TOO_OLD`); a manual game itself never gets it.
|
||||
`GET /api/rewards/expected?appids=` (read-only, at most 200) answers the full finish value per game for the Questions cards. `GET /api/rewards/games/{appid}` (game page open, after every status save) settles, then answers the paid rows of the game and its set (`bookedNow` = written by this call) and one `NoPointsReason` per missing kind (`STARTED`, `FINISHED`, `COMPLETED`); the codes live in `RewardRules`, the Frontend only maps them to texts; `earlySince` (ISO date, `app.rewards.early-since`) fills the date in the texts. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
|
||||
for finishes from before the status log. Shop: a whitelist of 57 looks (`ShopItem`; 45 buyable and 12 unlock-only) in eight slots, including animated completed effects, page backgrounds (CSS, SVG and two canvas engines in the Frontend),
|
||||
banner backgrounds (V27 widens `reward_equipped.slot`) and three buyable skins (full themes, slot `SKIN`). The unlock-only
|
||||
items (price 0, never sold): the settle books an `UNLOCK` row (ref = item code, 0 points) once a
|
||||
track reaches the item's target, retroactively and never lost. The five tracks (`Unlocks`): games finished
|
||||
(`FINISHED` + `FINISHED_MANUAL` rows + starter grant / 20), games completed, weeks in a row with a finish, months in
|
||||
a row with a `MONTH` bonus, months in a row with more Steam playtime than the month before (hidden games count too).
|
||||
A set (`user_game_set`, see "Editions of one game") pays `STARTED`, `FINISHED`/`FINISHED_MANUAL` and `COMPLETED` once: a
|
||||
game whose other edition was paid is no candidate, and two editions of a set give one candidate (the first event).
|
||||
A `BUY` or `UNLOCK` row proves ownership,
|
||||
`reward_equipped` holds what is worn. A platform admin owns every item (the role of the login token, or the `role`
|
||||
claim of the profile ticket for visitors).
|
||||
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but an AUDITOR can
|
||||
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
|
||||
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
|
||||
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
|
||||
game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
|
||||
and a user can split a key off a set for themselves (see "Editions of one game"). A set shares one status, rating,
|
||||
note and priority. Users can ask for a pair; the auditor queue holds user requests and the IGDB pairs not linked yet (`OPEN` only). Every link, unlink and dismissal goes to `admin_audit`
|
||||
(`GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`); auditors read that log through `GET /api/library/audit-log`, with
|
||||
the actor's display name from Auth's account state push.
|
||||
- **Wishlist**: synced from Steam with store price and discount.
|
||||
@@ -134,15 +192,15 @@ To Be Played is split into three repositories:
|
||||
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement
|
||||
icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times,
|
||||
then after 30 days.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
|
||||
them story / extras / 100 %. Optional: without credentials the feature is off and the rest works.
|
||||
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
|
||||
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
|
||||
ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
|
||||
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
|
||||
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
|
||||
and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
|
||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
||||
default. A section the user
|
||||
@@ -160,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
|
||||
@@ -190,24 +248,39 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
|
||||
| Method | Path | Auth |
|
||||
|---|---|---|
|
||||
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track) | JWT |
|
||||
| `GET` | `/api/rewards/wallet` (balance, catalog, owned, equipped, last 50 ledger rows with the game name, unlock `progress` per track, `earlySince` for the rules text) | JWT |
|
||||
| `GET` | `/api/rewards/expected?appids=` (`{appid: points}`, the full finish value, 0 when paid; read-only, no settle; `400 TOO_MANY` over 200 appids) | JWT |
|
||||
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
|
||||
| `GET` | `/api/rewards/games/{appid}` (settles, then the paid rows of the game and its set, `bookedNow`, one reason per missing kind; `404 GAME_NOT_FOUND`) | JWT |
|
||||
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
|
||||
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
|
||||
| `GET` | `/api/library/games` (`hideDuplicates=true` leaves out the lesser editions of a linked set), `/api/library/games/{appid}` | JWT |
|
||||
| `GET` | `/api/library/games` (`sort` = `playtimeMinutes`, `firstSeenAt`, `lastSeenAt`, `rating`, `priority`, `name`, `lastPlayedAt` or `timeToBeat` plus `,asc` or `,desc`; a game never played or without a time to beat comes last; one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored) | JWT |
|
||||
| `GET` | `/api/library/dashboard-games?top=10&playing=8` (`{top, playing}`: the Dashboard tiles in one call, listed entries by set playtime, most first; `playing` only status PLAYING; same rows and order as `/games` with `sort=playtimeMinutes,desc` / `status=PLAYING`, no count query; limits clamped to 1..20; calls `syncIfStale` like `/games`) | JWT |
|
||||
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
|
||||
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/games/{appid}/preview` (`{appid, name, imageUrl, genres, timeToBeat}`: catalog data of any game, a merged key answers with the kept key; `404 GAME_NOT_FOUND`) | JWT |
|
||||
| `GET` | `/api/library/catalog?q=&page=&size=` (every game some user still has, by name part, `PageDto` of `{appid, name, imageUrl, platforms}`; `q` at most 100 characters; size 20, 40 or 80) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/catalog/{appid}` (what the correction dialog reads: name, image, speedrun, moods, endless, genres, timeToBeat, corrections; `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/games/{appid}/merge` (`{otherAppid}`; `200 {appid}` = the key that stays, `400 SAME_GAME` / `STEAM_GAMES`, `404 GAME_NOT_FOUND`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/merges/{mergeId}/undo` (`mergeId` = the `id` of a `GAME_MERGE` audit row, within 30 days; `204`, `404 MERGE_NOT_FOUND`, `409 UNDO_NOT_AVAILABLE` / `NEWER_MERGE`, `410 UNDO_EXPIRED`) | JWT, AUDITOR |
|
||||
| `DELETE` | `/api/library/games/{appid}/set` (the user splits the key off its set for themselves; `204`, `404 NOT_LINKED` when no other listed edition is in the user's set) | JWT |
|
||||
| `POST`, `DELETE` | `/api/library/games/{appid}/link` (POST `{otherAppid}`, `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED`; DELETE takes the game out of its set, `204`, `404 NOT_LINKED`) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/audit-log?action=&page=&size=` (the newest `admin_audit` rows, `PageDto` of `{id, actorUserId, actorName, action, appid, gameName, targetAppid, targetGameName, detail, createdAt, undoable}`; `action` is `GAME_MERGE`, `GAME_MERGE_UNDO`, `GAME_LINK`, `GAME_UNLINK`, `GAME_LINK_DISMISS`, `GAME_CORRECT` or `GAME_CORRECTION_RESET`; size 20, 40 or 80) | JWT, AUDITOR |
|
||||
| `GET` | `/api/library/link-queue?page=&size=` (`PageDto` of `{a, b, igdb, requests, createdAt}`: both games, most requested first; size 20, 40 or 80) | JWT, AUDITOR |
|
||||
| `DELETE` | `/api/library/link-queue/{appid}/{otherAppid}` (dismiss; `204`, `404 PAIR_NOT_OPEN`) | JWT, AUDITOR |
|
||||
| `POST` | `/api/library/games/{appid}/link-requests` (`{otherAppid}`, both in the caller's library; `204`, `400 SAME_GAME`, `404 GAME_NOT_FOUND`, `409 ALREADY_LINKED` / `PAIR_DISMISSED`, `429 TOO_MANY_REQUESTS`) | JWT |
|
||||
| `PUT` | `/api/library/games/{appid}/status` (status only, keeps rating, notes, priority) | JWT |
|
||||
| `GET` | `/api/library/questions`, `/api/library/questions/count` (`{results: [{appid, name, imageUrl, question, status, playtimeMinutes, normallyMinutes, lastActivity}]}`, `{count}`) | JWT |
|
||||
| `POST` | `/api/library/questions/{appid}` (`{question, yes, rating?}`; `200 {next}`, `400 INVALID_ANSWER`, `404 GAME_NOT_FOUND`, `409 QUESTION_GONE`) | JWT |
|
||||
| `PUT` | `/api/library/games/{appid}/finished-hint` (`{dismissed: ENDING\|PLAYTIME}`, "Not yet"; `204`) | JWT |
|
||||
| `GET` | `/api/library/removed-games` (`{results: [{appid, name, imageUrl, source, platform, removedAt}]}`: the caller's removed `MANUAL` and `PSN` games, newest first) | JWT |
|
||||
| `POST` | `/api/library/games/{appid}/restore` (a removed game comes back with its old data; `204`, also when it is already back; `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
|
||||
| `GET` | `/api/library/game-search?q=` (IGDB search for "add a game", `q` 2-100 characters; `{available, results: [{appid, inLibrary, ...}]}`; `502 IGDB_FAILED`) | JWT |
|
||||
| `POST` | `/api/library/games` (`{igdbId, platform, status, playtimeMinutes?}`; `201 {appid}`, `400 UNKNOWN_GAME`, `409 ALREADY_IN_LIBRARY`, `503 IGDB_OFF`) | JWT |
|
||||
| `PUT` | `/api/library/games/{appid}/manual` (`{platform, playtimeMinutes}`, MANUAL and PSN rows, a PSN row keeps its playtime; `204`, `404 GAME_NOT_FOUND`, `409 STEAM_GAME`) | JWT |
|
||||
| `DELETE` | `/api/library/games/{appid}` (soft remove, same answers) | JWT |
|
||||
| `GET` | `/api/library/sync-changes` (`{syncedAt, startedBy, seen, importedGames, changes[]}` of the last sync; `204` when none) | JWT |
|
||||
| `PUT` | `/api/library/sync-changes/seen` (`{syncedAt}`; idempotent; `204`) | JWT |
|
||||
| `PUT` | `/api/library/games/{appid}/hidden` (`{hidden}`; Steam and manual games) | JWT |
|
||||
| `POST` | `/api/library/imports` (`{source: TEXT\|CSV\|SEARCH\|GALAXY\|GROUVEE\|HLTB\|PLAYNITE\|BOOKMARKLET, rows: [{title, platform, playtimeMinutes, status, igdbId}]}`, 1–1000 rows; `202 {id}` in `QUEUED`, `409 IMPORT_RUNNING`, `503 IGDB_OFF`) | JWT |
|
||||
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
|
||||
@@ -217,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) | 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 |
|
||||
@@ -225,17 +298,20 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
||||
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link) | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
|
||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
|
||||
| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | profile ticket |
|
||||
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
|
||||
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
|
||||
| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
|
||||
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
|
||||
| `GET` | `/api/follows` (`{following: [{id, displayName}], 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 |
|
||||
@@ -248,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
|
||||
|
||||
@@ -291,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
|
||||
```
|
||||
@@ -303,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 |
|
||||
@@ -314,14 +390,18 @@ flowchart LR
|
||||
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
|
||||
|
||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
||||
into its list) and writes `user_seen_at` and `last_synced_at` of `user_steam_sync` (`AccountStateRepository`); `library`
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`. A module that moves to its own service takes these reads along as calls.
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
|
||||
`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls.
|
||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
|
||||
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
|
||||
a column of `user_game` drops this view first, then `listed_user_game`.
|
||||
a column of `user_game` drops the views built on it first: `user_game_entry_playtime`, `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`.
|
||||
The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member`
|
||||
is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source
|
||||
and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key.
|
||||
|
||||
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
|
||||
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
||||
@@ -341,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
|
||||
@@ -354,8 +436,15 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
||||
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
||||
no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
||||
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
|
||||
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
||||
games, the library stays as it is.
|
||||
A game the user added by hand for PC, Steam, Mac or Linux (or with no platform), whose IGDB game is the IGDB
|
||||
game of a Steam appid the user owns for the first time (`game_edition`), becomes that Steam row. The row moves
|
||||
from minus its IGDB id to the appid and keeps status, rating, notes, priority, status history, backlog
|
||||
position, questions, set rows, PlayStation links and paid game rewards. Playtime and last played come from
|
||||
Steam. A hand-added game on another platform stays and joins the Steam game's set. A user with such a game
|
||||
gets the IGDB edition refresh before the save, so the mapping is known in time.
|
||||
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
|
||||
games and manual games. Hidden games only get their playtime updated.
|
||||
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
|
||||
@@ -363,7 +452,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
||||
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
|
||||
|
||||
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
|
||||
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`.
|
||||
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
|
||||
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
|
||||
|
||||
The frontend polls `GET /api/library/sync-status` until the run ends.
|
||||
|
||||
@@ -373,6 +463,8 @@ and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kin
|
||||
are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10
|
||||
minutes; it never takes a sync result, which may still wait for its achievements.
|
||||
|
||||
`ALL_UNLOCKED`: the sync unlocked the last Steam achievement of a game that is not COMPLETED; Core never sets COMPLETED itself, the user confirms it in the sync details.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant W as LibrarySyncWorker
|
||||
@@ -381,10 +473,11 @@ sequenceDiagram
|
||||
participant F as FollowDigestService
|
||||
participant D as Db
|
||||
W->>P: run (titles, trophy summaries, changed lists)
|
||||
P->>D: save title play (user_psn_title)
|
||||
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||
W-->>A: LibrarySyncedEvent
|
||||
A->>D: sync_change (ACHIEVEMENTS)
|
||||
A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED)
|
||||
A-->>F: LibraryActivityEvent
|
||||
F->>D: lock follow_digest, count sync + hand changes
|
||||
F->>D: inbox_note per follower (public, not blocked, not deleted)
|
||||
@@ -396,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
|
||||
@@ -415,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
|
||||
@@ -500,17 +597,73 @@ like `game_mood` (stale check, nightly sync, add, detail view; `app.library.edit
|
||||
`version_parent` for an edition, else `parent_game` when `game_type` is 9 (remaster) or 11 (port), else the game's own
|
||||
IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
|
||||
|
||||
- `game_link(appid, set_id)`: the keys an auditor linked, for every user. A set is every row with one `set_id`.
|
||||
- `game_link(appid, set_id)`: the keys that are one game, for every user. A set is every row with one `set_id`.
|
||||
An unlink takes one key out; a set of one is dropped. A merge puts the kept key in the merged key's set; when both have a set, the sets join.
|
||||
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is
|
||||
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
||||
- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
|
||||
editions refresh and once at start for the games already there (`linkExisting`). No auditor, so no audit row. A pair
|
||||
in `DISMISSED` (an auditor dismissal or an unlink) is never linked again, also not through a third edition: a link
|
||||
is skipped when a dismissed pair lies between the two sets. The link is global: a wrong IGDB key groups
|
||||
the game for every user until an auditor unlinks it.
|
||||
- **Split** (`DELETE /api/library/games/{appid}/set`): `user_game_split(user_id, appid)` makes the key stand alone for
|
||||
this user, only while another listed edition is in the user's set. No sync or link touches it, so it stays split.
|
||||
The view `user_game_set(user_id, appid, set_id)` is `game_link` for the user's rows minus the split keys; every
|
||||
reader of a user's set (`counted_user_game`, the editions of the detail, the `source` filter, the questions, rewards)
|
||||
reads it. There is no undo of a split yet.
|
||||
- **One status per set** (V42 triggers, like V14, so no code path can forget it): a change of `status`,
|
||||
`status_set_by_user`, `rating`, `notes` or `priority` on one row is copied to the other rows of the user's set,
|
||||
also hidden and removed ones (`user_game_set_share`; `pg_trigger_depth()` stops the echo). When rows join a set (a new
|
||||
row, a new link, a merge of sets), `align_user_game_set` applies the join rule: the winner is the counted edition
|
||||
(listed first, then status rank, playtime, older `first_seen_at`, lower appid); its status wins, `status_set_by_user`
|
||||
is true if any row has it, rating and priority fall back to the first row that has one, and the distinct notes are
|
||||
joined with a blank line, cut to 2000. A row that changes is first copied to `user_game_set_backup` (the first copy
|
||||
wins; kept until the account is deleted). The status log of a join is no hand change (`by_user` false), and a row that
|
||||
takes a finished status takes the set's first finish time, so "finished per year" does not move.
|
||||
- `game_link_pair` is the auditor queue (`OPEN`, `DISMISSED`, `LINKED`). IGDB pairs are inserted when the queue is read. A row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
||||
members. `game_link_request` holds who asked for a pair (the queue shows only the count). A user asks for a pair of
|
||||
two games of their own library; at most 20 open requests per user (a per-user advisory lock makes the cap hold for parallel calls), a dismissed pair answers `409 PAIR_DISMISSED`.
|
||||
- Pile, stats, snapshots and the dashboard count a set once (`counted_user_game`, see "Stats"); the library
|
||||
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
|
||||
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
|
||||
library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their
|
||||
drill-downs. There is no "Hide duplicates" filter any more; a hidden game is its own row under `hidden=true`. The
|
||||
friends row of the picker matches a friend's game to the caller's entry through `game_link`. Kept per edition on
|
||||
purpose: the hidden list, the platform stat, the average achievement completion (per edition; the tile's game counts are per entry) and the
|
||||
community stats of the landing page. The playtime of a card, the playtime sort and the stats is the set total (`user_game_entry_playtime`, V45, see "Detail per platform"). Pile Points pay a set once (see "Pile Points"), and the
|
||||
`MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
||||
The library query joins `user_game_entry_playtime` on every page (about +4 ms per page); upgrade path: join it only for the playtime sort or the PLAYING status, with a second query.
|
||||
- **Detail per platform** (V44): the PSN step stores platform, minutes and last played per title in `user_psn_title`
|
||||
(`LINKED` and `STEAM_OWNED`, every sync, whatever row the title is on); the view `psn_title_play` resolves a merged
|
||||
key through `game_alias`. `PlatformPlay.lines` is the one rule for the detail lines: a Steam or manual row is one
|
||||
line, a PlayStation row is its titles (or one line from the row before the first sync after V44), a manual row is
|
||||
replaced by the titles of its own platform, and every other title linked to a Steam or manual row adds its
|
||||
PlayStation line. The total is their sum; the view `user_game_entry_playtime` (V45) is the same rule in SQL, a test
|
||||
compares both. The card total (`GameLinkRepository.entryPlaytimes`), the playtime sort of the library, `sumPlaytime`
|
||||
and the stats read that view; it has no correlated subquery on purpose (it made a library page cost 2 s). Each line carries its own
|
||||
achievement or trophy progress; the full list stays on the edition's own detail page. Still per row, not per
|
||||
platform: the playtime history (PSN minutes on a Steam row are not in it) and rewards (Steam only). Until a user's next sync after V44, PSN-on-Steam
|
||||
lines are missing and Steam's last played is empty.
|
||||
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
|
||||
(size of the set in the user's library, `null` below 2).
|
||||
(size of the set in the user's library, `null` below 2), `platforms` (`source` + `platform` per edition, for
|
||||
the icons of the card) and `totalPlaytimeMinutes`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Edition["game_edition (IGDB same_game_id)"] -->|"linkSameGames"| Link["game_link (global sets)"]
|
||||
Auditor["auditor link / unlink"] --> Link
|
||||
Link --> Set["user_game_set"]
|
||||
Split["user_game_split (user splits)"] -->|"minus"| Set
|
||||
Set --> Counted["counted_user_game"]
|
||||
Counted --> Member["user_game_entry_member"]
|
||||
Member --> Platform["user_game_entry_platform"]
|
||||
Platform -->|"platforms[]"| Card["library card, lists"]
|
||||
EntryPlaytime -->|"totalPlaytimeMinutes"| Card
|
||||
EntryPlaytime --> Stats["library sort, stats, public profile"]
|
||||
Member --> EntryPlaytime["user_game_entry_playtime (V45)"]
|
||||
Title["user_psn_title play (V44)"] -->|"psn_title_play"| EntryPlaytime
|
||||
Title -->|"platforms[] of the detail"| Detail["game detail"]
|
||||
Set -->|"join: align_user_game_set"| Row["user_game (status, rating, notes, priority)"]
|
||||
Row -->|"update: user_game_set_share"| Row
|
||||
Row -.->|"old values"| Backup["user_game_set_backup"]
|
||||
Set --> Rewards["rewards: a set pays once"]
|
||||
```
|
||||
|
||||
### Deleted accounts
|
||||
|
||||
@@ -521,7 +674,8 @@ user ids, which removes the rows of users Auth does not know (refused for more t
|
||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
|
||||
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
|
||||
The purge also removes the user's `account_name` and `listed_user` rows and link requests (`game_link_request`).
|
||||
The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits
|
||||
(`user_game_split`) and set backups (`user_game_set_backup`).
|
||||
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
|
||||
|
||||
### Blocked users and Steam ID changes
|
||||
@@ -548,11 +702,21 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games
|
||||
`user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with
|
||||
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
|
||||
`STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||
or removed (`DELETE /api/library/games/{appid}`, soft: it sets `removed_at`, the row keeps status, rating, notes
|
||||
and history) and restored (`POST /api/library/games/{appid}/restore`, see the diagram). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
|
||||
a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two
|
||||
(see "Merging duplicates").
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Listed: add / sync
|
||||
Listed --> Removed: DELETE /games/{appid} (MANUAL, PSN)
|
||||
Removed --> Listed: POST /games/{appid}/restore or Undo
|
||||
Removed: removed_at set, data kept
|
||||
Removed: PSN sync skips the row
|
||||
```
|
||||
|
||||
### Imports
|
||||
|
||||
Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one
|
||||
@@ -621,9 +785,15 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
|
||||
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
|
||||
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
|
||||
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
|
||||
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
|
||||
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
|
||||
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
|
||||
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
|
||||
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
|
||||
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
|
||||
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
|
||||
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
|
||||
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
|
||||
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
|
||||
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
|
||||
- The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in
|
||||
`sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the
|
||||
follow digest need no change.
|
||||
@@ -674,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:
|
||||
@@ -715,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:
|
||||
|
||||
@@ -729,7 +900,24 @@ 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
|
||||
|
||||
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
|
||||
duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
|
||||
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
|
||||
that leaves the filter chain is logged as `500`.
|
||||
|
||||
### Database connection settings
|
||||
|
||||
Every Core connection runs with `plan_cache_mode = force_generic_plan` and `prepareThreshold=1`
|
||||
(`spring.datasource.hikari` in `application.yml`). Planning was 94% of the Dashboard DB time and prod never reused a
|
||||
plan (few users, a new connection every 30 min). With a cached generic plan the Dashboard burst dropped from 443 to
|
||||
229 ms on 2 CPUs (#49). Cost: the first burst on fresh connections costs about 100 ms more (cold start: 1101 -> 1205
|
||||
ms), and a generic plan ignores parameter values.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
||||
* The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
||||
* asks again.
|
||||
*/
|
||||
@Configuration
|
||||
@@ -43,4 +43,14 @@ public class AsyncConfig {
|
||||
executor.setThreadNamePrefix("sync-follow-up-");
|
||||
return executor;
|
||||
}
|
||||
|
||||
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
|
||||
@Bean(name = BackgroundPools.SPEEDRUN)
|
||||
public ThreadPoolTaskExecutor speedrunExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(1);
|
||||
executor.setThreadNamePrefix("speedrun-");
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package core_service.configuration;
|
||||
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.jspecify.annotations.NonNull;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
/**
|
||||
* One INFO line per request: method, handler pattern, status, duration. The pattern (e.g. {@code /api/games/{appid}})
|
||||
* stands for the path, so the line holds no ids, query, user or credentials. A request no handler took shows {@code -}.
|
||||
*/
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE)
|
||||
public class RequestTimingFilter extends OncePerRequestFilter {
|
||||
|
||||
private static final Logger LOG = LoggerFactory.getLogger(RequestTimingFilter.class);
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(
|
||||
@NonNull HttpServletRequest request,
|
||||
@NonNull HttpServletResponse response,
|
||||
@NonNull FilterChain filterChain
|
||||
) throws ServletException, IOException {
|
||||
long start = System.nanoTime();
|
||||
int status = HttpServletResponse.SC_INTERNAL_SERVER_ERROR;
|
||||
try {
|
||||
filterChain.doFilter(request, response);
|
||||
status = response.getStatus();
|
||||
} finally {
|
||||
// An exception that leaves the chain ends as a 500; the container sets it only after this filter.
|
||||
Object pattern = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE);
|
||||
LOG.info("{} {} {} {} ms", request.getMethod(), pattern != null ? pattern : "-", status,
|
||||
(System.nanoTime() - start) / 1_000_000);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The health check calls the actuator every 30 s; its lines would flood the log.
|
||||
*/
|
||||
@Override
|
||||
protected boolean shouldNotFilter(@NonNull HttpServletRequest request) {
|
||||
return request.getRequestURI().startsWith("/actuator/");
|
||||
}
|
||||
}
|
||||
@@ -19,10 +19,10 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AccountCleanupRepository {
|
||||
|
||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
// Children before user_game (several would also go by its cascade), the tombstones last.
|
||||
static final List<String> TABLES = List.of("user_game_question", "user_game_status_change", "user_game_achievement",
|
||||
"user_game_achievement_meta", "user_game_playtime_day", "wishlist_game", "backlog_snapshot", "backlog_order",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
|
||||
"sync_change", "sync_result", "user_steam_sync", "game_merge_undo_user", "user_psn_trophy", "user_psn_trophy_set", "user_game_split", "user_game_set_backup", "user_game", "game_link_request", "account_name", "game_import", "user_psn_title", "user_psn_account", "reward_ledger", "reward_equipped",
|
||||
"account_role_change", "blocked_user", "listed_user", "follow", "follow_digest", "inbox_note", "public_user", "deleted_user");
|
||||
|
||||
// Every user with data has a row in one of these; deleteRows then removes the child rows too.
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
@@ -8,9 +9,10 @@ import java.util.List;
|
||||
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 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 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;
|
||||
|
||||
/**
|
||||
@@ -41,20 +47,20 @@ public class FollowDigestRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden games never count. Wishlist adds are counted apart, see {@link #countWishlist}.
|
||||
* Hidden and removed games never count. Wishlist adds are counted apart, see {@link #countWishlist}.
|
||||
*/
|
||||
public Digest countSync(long userId) {
|
||||
return jdbc.queryForObject("""
|
||||
select
|
||||
(select count(distinct c.appid) from sync_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null) as new_games,
|
||||
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null) as new_games,
|
||||
(select coalesce(sum(c.amount), 0) from sync_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null) as achievements,
|
||||
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null) as achievements,
|
||||
(select count(*) from sync_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null) as status_changes""",
|
||||
where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null and ug.removed_at is null) as status_changes""",
|
||||
user(userId),
|
||||
(rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0));
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
@@ -93,11 +110,95 @@ public class FollowDigestRepository {
|
||||
from user_game_status_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId
|
||||
and ug.hidden_at is null""",
|
||||
and ug.hidden_at is null and ug.removed_at is null""",
|
||||
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
|
||||
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
|
||||
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
|
||||
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
|
||||
*/
|
||||
public DigestItems findSyncItems(long userId) {
|
||||
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
|
||||
items.addAll(findSyncAchievements(userId));
|
||||
return new DigestItems(items);
|
||||
}
|
||||
|
||||
private List<DigestItem> findSyncNewGames(long userId) {
|
||||
return jdbc.query("""
|
||||
select distinct c.appid, sg.name from sync_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
left join steam_game sg on sg.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
|
||||
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
|
||||
}
|
||||
|
||||
private List<DigestItem> findSyncAchievements(long userId) {
|
||||
return jdbc.query("""
|
||||
with gained as (
|
||||
select c.appid, sum(c.amount) as amount from sync_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
|
||||
group by c.appid),
|
||||
unlocks as (
|
||||
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
|
||||
from user_game_achievement a
|
||||
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
|
||||
where a.user_id = :userId and a.unlocked
|
||||
union all
|
||||
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
|
||||
from user_psn_trophy ut
|
||||
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
|
||||
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
|
||||
where ut.user_id = :userId),
|
||||
ranked as (
|
||||
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
|
||||
from unlocks u join gained g on g.appid = u.appid)
|
||||
select r.appid, sg.name as game_name, r.name from ranked r
|
||||
join gained g on g.appid = r.appid
|
||||
left join steam_game sg on sg.appid = r.appid
|
||||
where r.rn <= least(g.amount, :cap)
|
||||
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
|
||||
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
|
||||
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
|
||||
*/
|
||||
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
|
||||
return new DigestItems(jdbc.query("""
|
||||
select c.appid, sg.name, c.from_status, c.to_status
|
||||
from user_game_status_change c
|
||||
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||
left join steam_game sg on sg.appid = c.appid
|
||||
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
|
||||
and (c.by_user or (:withSyncMoves and c.from_status is not null))
|
||||
and ug.hidden_at is null and ug.removed_at is null
|
||||
order by c.id""",
|
||||
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
|
||||
(rs, i) -> rs.getString("from_status") == null
|
||||
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
|
||||
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
|
||||
rs.getString("to_status"))));
|
||||
}
|
||||
|
||||
/**
|
||||
* The lines behind {@link #countWishlist}, same id range.
|
||||
*/
|
||||
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
|
||||
return new DigestItems(jdbc.query("""
|
||||
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
|
||||
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
|
||||
left join steam_game sg on sg.appid = c.appid
|
||||
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
|
||||
order by c.id""",
|
||||
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
|
||||
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return null when there is no row or the user did not send sections: nothing is filtered
|
||||
*/
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -77,6 +78,9 @@ public class IgdbApiService {
|
||||
*/
|
||||
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
||||
|
||||
/** @param year null when IGDB knows no release date */
|
||||
public record NameAndYear(String name, Integer year) {}
|
||||
|
||||
// IGDB's own order puts obscure entries first, so Core asks for more and sorts by popularity itself.
|
||||
private static final int SEARCH_FETCH_LIMIT = 50;
|
||||
private static final int SEARCH_LIMIT = 20;
|
||||
@@ -382,18 +386,31 @@ public class IgdbApiService {
|
||||
* Like {@link #getReleaseDates}, but for IGDB game ids.
|
||||
*/
|
||||
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
|
||||
return inBatches(igdbIds, "release dates", this::datesOf);
|
||||
}
|
||||
|
||||
/**
|
||||
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
|
||||
* when the adapter is off, nothing was asked or a request failed.
|
||||
*/
|
||||
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
|
||||
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
|
||||
}
|
||||
|
||||
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
|
||||
Function<List<Long>, Map<Long, T>> batchQuery) {
|
||||
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<Long> ids = List.copyOf(igdbIds);
|
||||
Map<Long, LocalDate> result = new HashMap<>();
|
||||
Map<Long, T> result = new HashMap<>();
|
||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||
result.putAll(datesOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||
result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||
}
|
||||
return Optional.of(result);
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with IGDB for release dates", e);
|
||||
log.error("Error communicating with IGDB for {}", what, e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
@@ -601,10 +618,7 @@ public class IgdbApiService {
|
||||
if (igdbIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
IgdbResponses.Game[] games = query("/games",
|
||||
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.Game[].class);
|
||||
IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
|
||||
Map<Long, LocalDate> result = new HashMap<>();
|
||||
for (IgdbResponses.Game game : games) {
|
||||
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
||||
@@ -614,6 +628,23 @@ public class IgdbApiService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, NameAndYear> namesAndYearsOf(Collection<Long> igdbIds) {
|
||||
Map<Long, NameAndYear> result = new HashMap<>();
|
||||
for (IgdbResponses.Game game : gamesById(igdbIds, "name,first_release_date")) {
|
||||
String name = trimmedName(game);
|
||||
if (game.id() != null && igdbIds.contains(game.id()) && !name.isEmpty()) {
|
||||
result.put(game.id(), new NameAndYear(name, game.firstReleaseDate() == null ? null : year(game.firstReleaseDate())));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private IgdbResponses.Game[] gamesById(Collection<Long> igdbIds, String fields) {
|
||||
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
return query("/games", "fields " + fields + "; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.Game[].class);
|
||||
}
|
||||
|
||||
private static int year(long unixSeconds) {
|
||||
return date(unixSeconds).getYear();
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -36,7 +38,9 @@ import core_service.modules.library.domain.AuditEntry;
|
||||
import core_service.modules.library.domain.CatalogGame;
|
||||
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;
|
||||
@@ -98,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;
|
||||
@@ -121,7 +127,8 @@ public class LibraryController {
|
||||
boolean syncStarted = syncService.syncIfStale(userId, steamId);
|
||||
long count = queryService.countOwnedGames(userId);
|
||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
|
||||
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted));
|
||||
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted,
|
||||
"sources", queryService.listSources(userId)));
|
||||
}
|
||||
|
||||
@GetMapping("/games")
|
||||
@@ -134,7 +141,7 @@ public class LibraryController {
|
||||
@RequestParam(required = false) String q,
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@RequestParam(defaultValue = "false") boolean hidden,
|
||||
@RequestParam(defaultValue = "false") boolean hideDuplicates
|
||||
@RequestParam(required = false) List<GameSource> source
|
||||
) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
|
||||
@@ -142,10 +149,20 @@ public class LibraryController {
|
||||
int safeSize = ALLOWED_SIZES.contains(size) ? size : DEFAULT_SIZE;
|
||||
int safePage = Math.max(0, page);
|
||||
Sort safeSort = parseSort(sort);
|
||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hideDuplicates, PageRequest.of(safePage, safeSize, safeSort));
|
||||
var dtoPage = queryService.listOwnedGames(userId, hidden, status, q, maxMinutes != null && maxMinutes > 0 ? maxMinutes : null, hidden ? null : source, PageRequest.of(safePage, safeSize, safeSort));
|
||||
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;
|
||||
@@ -175,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));
|
||||
@@ -296,6 +322,17 @@ public class LibraryController {
|
||||
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED", "The game is not linked"));
|
||||
}
|
||||
|
||||
/**
|
||||
* The user says the game is not the same as its other editions: it stands alone for this user from now on.
|
||||
*/
|
||||
@DeleteMapping("/games/{appid}/set")
|
||||
public ResponseEntity<?> splitGame(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("appid") Long appid) {
|
||||
return gameLinkService.split(principal.getIdAsLong(), appid) ? ResponseEntity.noContent().build()
|
||||
: ResponseEntity.status(HttpStatus.NOT_FOUND).body(new ErrorResponse("NOT_LINKED",
|
||||
"The game has no other edition in your library"));
|
||||
}
|
||||
|
||||
@GetMapping("/link-queue")
|
||||
public ResponseEntity<PageDto<LinkPairDto>> getLinkQueue(@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "20") int size) {
|
||||
@@ -341,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());
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import core_service.modules.library.api.dto.AddGameRequest;
|
||||
import core_service.modules.library.api.dto.AddedGameDto;
|
||||
import core_service.modules.library.api.dto.GameSearchDto;
|
||||
import core_service.modules.library.api.dto.ManualGameUpdate;
|
||||
import core_service.modules.library.api.dto.RemovedGamesDto;
|
||||
import core_service.modules.library.service.ManualGameService;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -79,6 +80,20 @@ public class ManualGameController {
|
||||
return toResponse(manualGameService.remove(principal.getIdAsLong(), appid));
|
||||
}
|
||||
|
||||
@PostMapping("/games/{appid}/restore")
|
||||
public ResponseEntity<?> restore(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@PathVariable("appid") long appid) {
|
||||
return toResponse(manualGameService.restore(principal.getIdAsLong(), appid));
|
||||
}
|
||||
|
||||
@GetMapping("/removed-games")
|
||||
public RemovedGamesDto removedGames(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return new RemovedGamesDto(manualGameService.removedGames(principal.getIdAsLong()).stream()
|
||||
.map(game -> new RemovedGamesDto.Game(game.appid(), game.name(), game.imageUrl(), game.source().name(),
|
||||
game.platform(), game.removedAt()))
|
||||
.toList());
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> toResponse(ManualGameService.ChangeResult result) {
|
||||
return switch (result) {
|
||||
case DONE -> ResponseEntity.noContent().build();
|
||||
|
||||
@@ -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,59 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.api.dto.AnsweredDto;
|
||||
import core_service.modules.library.api.dto.QuestionAnswer;
|
||||
import core_service.modules.library.api.dto.QuestionDto;
|
||||
import core_service.modules.library.api.dto.QuestionsDto;
|
||||
import core_service.modules.library.domain.Question;
|
||||
import core_service.modules.library.service.QuestionService;
|
||||
import core_service.shared.api.ErrorResponse;
|
||||
import core_service.shared.security.UserPrincipal;
|
||||
import jakarta.validation.Valid;
|
||||
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.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/library/questions")
|
||||
@RequiredArgsConstructor
|
||||
public class QuestionController {
|
||||
|
||||
private final QuestionService questionService;
|
||||
|
||||
@GetMapping
|
||||
public QuestionsDto list(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return new QuestionsDto(questionService.open(principal.getIdAsLong()).stream().map(QuestionDto::from).toList());
|
||||
}
|
||||
|
||||
@GetMapping("/count")
|
||||
public Map<String, Integer> count(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return Map.of("count", questionService.count(principal.getIdAsLong()));
|
||||
}
|
||||
|
||||
@PostMapping("/{appid}")
|
||||
public ResponseEntity<?> answer(@AuthenticationPrincipal UserPrincipal principal, @PathVariable Long appid,
|
||||
@Valid @RequestBody QuestionAnswer answer) {
|
||||
if (answer.question() == Question.RATE && answer.yes() && answer.rating() == null) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(new ErrorResponse("INVALID_ANSWER", "A yes to RATE needs a rating"));
|
||||
}
|
||||
QuestionService.Answered answered = questionService.answer(principal.getIdAsLong(), appid, answer.question(),
|
||||
answer.yes(), answer.rating());
|
||||
return switch (answered.result()) {
|
||||
case DONE -> ResponseEntity.ok(new AnsweredDto(answered.next().map(QuestionDto::from).orElse(null)));
|
||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||
.body(new ErrorResponse("GAME_NOT_FOUND", "Game not in user library"));
|
||||
case GONE -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
.body(new ErrorResponse("QUESTION_GONE", "The question is not open any more"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
/** @param next the next open question of the same game; null when there is none */
|
||||
public record AnsweredDto(QuestionDto next) {}
|
||||
@@ -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) {}
|
||||
@@ -16,7 +16,7 @@ public record GameDetailDto(
|
||||
int playtime2Weeks,
|
||||
Instant firstSeenAt,
|
||||
Instant lastSeenAt,
|
||||
// from PlayStation; null for Steam rows
|
||||
// from PlayStation or Steam; null when not known
|
||||
Instant lastPlayedAt,
|
||||
GameStatus status,
|
||||
Integer rating,
|
||||
@@ -57,7 +57,13 @@ public record GameDetailDto(
|
||||
// see GameDto#comingOn
|
||||
LocalDate comingOn,
|
||||
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
|
||||
List<AchievementSetDto> achievementSets
|
||||
List<AchievementSetDto> achievementSets,
|
||||
// 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,
|
||||
// true when this answer skipped a due Steam refresh (refresh=false); the same call without it refreshes
|
||||
boolean achievementsStale
|
||||
) {
|
||||
|
||||
/**
|
||||
|
||||
@@ -5,6 +5,7 @@ import core_service.modules.library.domain.GameSource;
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* @param timeToBeatMinutes IGDB's normal time to beat, null when unknown
|
||||
@@ -17,6 +18,9 @@ import java.time.LocalDate;
|
||||
* @param editions how many games of the user are in this game's linked set, null below 2
|
||||
* @param coming the game is not released yet and nobody played it; it does not count on the pile
|
||||
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
|
||||
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
|
||||
* @param totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted
|
||||
* edition's own
|
||||
*/
|
||||
public record GameDto(
|
||||
Long appid,
|
||||
@@ -36,5 +40,7 @@ public record GameDto(
|
||||
boolean hidden,
|
||||
Integer editions,
|
||||
boolean coming,
|
||||
LocalDate comingOn
|
||||
LocalDate comingOn,
|
||||
List<PlatformDto> platforms,
|
||||
int totalPlaytimeMinutes
|
||||
) {}
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
|
||||
/**
|
||||
* @param platform e.g. "PS5"; null when the edition has none
|
||||
*/
|
||||
public record PlatformDto(GameSource source, String platform) {}
|
||||
@@ -0,0 +1,18 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.GameSource;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One platform line of a game's detail.
|
||||
*
|
||||
* @param platform e.g. "PS5"; null when the edition has none
|
||||
* @param appid the edition the line belongs to; its own detail page lists the achievements
|
||||
* @param lastPlayedAt null when not known
|
||||
* @param achievementsUnlocked null when the platform has no achievements for the user
|
||||
* @param achievementsTotal null when the platform has no achievements for the user
|
||||
* @param perfect every achievement unlocked, or every trophy set perfect
|
||||
*/
|
||||
public record PlatformPlayDto(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt,
|
||||
Integer achievementsUnlocked, Integer achievementsTotal, boolean perfect) {}
|
||||
@@ -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,12 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.Question;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
/**
|
||||
* @param yes yes changes the game; no snoozes the question
|
||||
* @param rating 1 to 5; needed for a yes to RATE
|
||||
*/
|
||||
public record QuestionAnswer(@NotNull Question question, @NotNull Boolean yes, @Min(1) @Max(5) Integer rating) {}
|
||||
@@ -0,0 +1,22 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.GameStatus;
|
||||
import core_service.modules.library.domain.Question;
|
||||
import core_service.modules.library.service.QuestionService;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A game with an open question.
|
||||
*
|
||||
* @param lastActivity the last sign of play; only for DROPPED
|
||||
*/
|
||||
public record QuestionDto(long appid, String name, String imageUrl, Question question, GameStatus status,
|
||||
int playtimeMinutes, Integer normallyMinutes, Instant lastActivity) {
|
||||
|
||||
public static QuestionDto from(QuestionService.Open open) {
|
||||
return new QuestionDto(open.game().appid(), open.game().name(), open.game().imageUrl(),
|
||||
open.question().question(), open.game().status(), open.game().playtimeMinutes(),
|
||||
open.game().normallyMinutes(), open.question().lastActivity());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public record QuestionsDto(List<QuestionDto> results) {}
|
||||
@@ -0,0 +1,9 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public record RemovedGamesDto(List<Game> results) {
|
||||
|
||||
public record Game(long appid, String name, String imageUrl, String source, String platform, Instant removedAt) {}
|
||||
}
|
||||
@@ -24,6 +24,8 @@ public record UserStatsDto(
|
||||
) {
|
||||
|
||||
/**
|
||||
* @param playedGames games with a set total above 0
|
||||
* @param neverStarted games with a set total of 0
|
||||
* @param byStatus has every status, also those with 0 games
|
||||
*/
|
||||
public record Overview(long totalGames, long totalPlaytimeMinutes, long playedGames, long neverStarted,
|
||||
@@ -56,6 +58,7 @@ public record UserStatsDto(
|
||||
/**
|
||||
* @param moonTrips the total playtime as flights to the moon and back (Apollo 11, 195.3 hours)
|
||||
* @param longestDay the day with the most playtime; null without history
|
||||
* @param underOneHourGames games whose set total is 1 to 59 minutes
|
||||
* @param underOneHourShare share of the started games with less than one hour; null when none is started
|
||||
*/
|
||||
public record Habits(double moonTrips, DayMinutes longestDay, long underOneHourGames, Double underOneHourShare) {}
|
||||
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A platform a library entry is on, e.g. {@code PSN} + "PS5".
|
||||
*
|
||||
* @param platform null when the edition has none, e.g. a Steam game
|
||||
*/
|
||||
public record EntryPlatform(GameSource source, String platform) {}
|
||||
@@ -0,0 +1,62 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneId;
|
||||
import java.util.Objects;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* The open question of a game in the question queue. Only a question: the user decides.
|
||||
*
|
||||
* @param lastActivity the last sign of play, for the Dropped? text; null for the other questions
|
||||
*/
|
||||
public record OpenQuestion(Question question, Instant lastActivity) {
|
||||
|
||||
static final int SNOOZE_MINUTES = 300;
|
||||
static final int FINISHED_FALLBACK_MINUTES = 600;
|
||||
static final Duration IDLE = Duration.ofDays(90);
|
||||
|
||||
/** The first rule that matches wins. Every rule starts from the status, so an automatic status change closes it. */
|
||||
public static Optional<OpenQuestion> of(QuestionFacts facts, Instant now, ZoneId zone) {
|
||||
GameStatus status = facts.status();
|
||||
int playtime = facts.playtimeMinutes();
|
||||
if (status == GameStatus.BACKLOG && playtime > 0 && isDue(playtime, facts.startAskedAt())) {
|
||||
return Optional.of(new OpenQuestion(Question.START, null));
|
||||
}
|
||||
if ((status == GameStatus.BACKLOG || status == GameStatus.PLAYING) && !Boolean.TRUE.equals(facts.endless())
|
||||
&& playtime >= finishedMinutes(facts) && isDue(playtime, facts.finishedAskedAt())) {
|
||||
return Optional.of(new OpenQuestion(Question.FINISHED, null));
|
||||
}
|
||||
if (status == GameStatus.PLAYING && facts.playtime2Weeks() == 0) {
|
||||
Instant lastActivity = lastActivity(facts, zone);
|
||||
if (lastActivity.isBefore(now.minus(IDLE))) return Optional.of(new OpenQuestion(Question.DROPPED, lastActivity));
|
||||
}
|
||||
if ((status == GameStatus.FINISHED || status == GameStatus.COMPLETED) && facts.rating() == null
|
||||
&& !facts.rateSkipped()) {
|
||||
return Optional.of(new OpenQuestion(Question.RATE, null));
|
||||
}
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
private static boolean isDue(int playtime, Integer askedAt) {
|
||||
return askedAt == null || playtime >= askedAt + SNOOZE_MINUTES;
|
||||
}
|
||||
|
||||
private static int finishedMinutes(QuestionFacts facts) {
|
||||
Integer normally = facts.normallyMinutes();
|
||||
return normally != null && normally > 0 ? normally : FINISHED_FALLBACK_MINUTES;
|
||||
}
|
||||
|
||||
/** The first sighting is the floor, so a new user waits the full idle time. */
|
||||
private static Instant lastActivity(QuestionFacts facts, ZoneId zone) {
|
||||
Instant playDay = facts.lastPlayDay() == null ? null
|
||||
: facts.lastPlayDay().plusDays(1).atStartOfDay(zone).toInstant();
|
||||
return Stream.of(playDay, facts.lastPlayedAt(), facts.lastStatusChange(), facts.firstSeenAt(),
|
||||
facts.droppedAnsweredAt())
|
||||
.filter(Objects::nonNull)
|
||||
.max(Instant::compareTo)
|
||||
.orElse(Instant.EPOCH);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* One platform's play of a game entry: what the detail lists and the card totals. The view
|
||||
* {@code user_game_entry_playtime} (V45) follows the same rule; a test pins both.
|
||||
*
|
||||
* @param platform e.g. "PS5"; null when the edition has none
|
||||
* @param appid the edition the line comes from
|
||||
* @param lastPlayedAt null when not known
|
||||
*/
|
||||
public record PlatformPlay(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt) {
|
||||
|
||||
private static final Comparator<PlatformPlay> ORDER = Comparator
|
||||
.comparingInt((PlatformPlay line) -> line.source() == GameSource.STEAM ? 0
|
||||
: line.source() == GameSource.PSN ? 1 : 2)
|
||||
.thenComparing(PlatformPlay::platform, Comparator.nullsLast(Comparator.reverseOrder()))
|
||||
.thenComparingLong(PlatformPlay::appid);
|
||||
|
||||
/**
|
||||
* A Steam row is one line; a PlayStation row is its titles, or itself while it has no stored title
|
||||
* play; a manual row is replaced by the titles of its own platform; every title with stored play on any row adds its own PlayStation line.
|
||||
*
|
||||
* @param titles the stored play of the titles linked to the rows; other keys are ignored
|
||||
*/
|
||||
public static List<PlatformPlay> lines(List<UserGame> rows, List<TitlePlay> titles) {
|
||||
List<PlatformPlay> lines = new ArrayList<>();
|
||||
for (UserGame row : rows) {
|
||||
List<TitlePlay> own = titles.stream().filter(title -> row.getAppid().equals(title.appid())).toList();
|
||||
boolean replaced = switch (row.getSource()) {
|
||||
case PSN -> !own.isEmpty();
|
||||
case MANUAL -> own.stream().anyMatch(title -> row.getPlatform() != null
|
||||
&& row.getPlatform().equals(title.platform()));
|
||||
default -> false;
|
||||
};
|
||||
if (!replaced) {
|
||||
lines.add(new PlatformPlay(row.getSource(), row.getPlatform(), row.getAppid(), row.getPlaytimeMinutes(),
|
||||
row.getLastPlayedAt()));
|
||||
}
|
||||
own.forEach(title -> lines.add(new PlatformPlay(GameSource.PSN, title.platform(), row.getAppid(),
|
||||
title.playtimeMinutes(), title.lastPlayedAt())));
|
||||
}
|
||||
lines.sort(ORDER);
|
||||
return lines;
|
||||
}
|
||||
|
||||
public static int total(List<PlatformPlay> lines) {
|
||||
return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -6,4 +6,9 @@ package core_service.modules.library.domain;
|
||||
* @param listed true when the trophy rows are stored; false until a sync has fetched them
|
||||
*/
|
||||
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
|
||||
boolean platinumEarned, boolean listed) {}
|
||||
boolean platinumEarned, boolean listed) {
|
||||
|
||||
public boolean perfect() {
|
||||
return platinumEarned || earned >= total;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/** A question of the question queue. The declaration order is the ask order: one question per game at a time. */
|
||||
public enum Question {
|
||||
START,
|
||||
FINISHED,
|
||||
DROPPED,
|
||||
RATE
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
|
||||
/**
|
||||
* Everything {@link OpenQuestion#of} needs to know about one game of one user.
|
||||
*
|
||||
* @param normallyMinutes IGDB's normal time to beat; null when unknown
|
||||
* @param endless see {@link Mood#endless}; null when IGDB does not know the game
|
||||
* @param lastPlayDay the last day with Steam playtime
|
||||
* @param lastStatusChange the last time the status changed
|
||||
* @param startAskedAt the playtime at the "No" to Start it?; null when never answered
|
||||
* @param finishedAskedAt the playtime at the "No" to Finished?; null when never answered
|
||||
* @param droppedAnsweredAt the time of the "No" to Dropped?; null when never answered
|
||||
*/
|
||||
public record QuestionFacts(long appid, String name, String imageUrl, GameStatus status, Integer rating,
|
||||
int playtimeMinutes, int playtime2Weeks, Integer normallyMinutes, Boolean endless,
|
||||
LocalDate lastPlayDay, Instant lastPlayedAt, Instant lastStatusChange, Instant firstSeenAt,
|
||||
Integer startAskedAt, Integer finishedAskedAt, Instant droppedAnsweredAt,
|
||||
boolean rateSkipped) {}
|
||||
@@ -0,0 +1,15 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
/**
|
||||
* A library entry played in the last two weeks; the playtimes add up every edition of the entry.
|
||||
*/
|
||||
public interface RecentEntry {
|
||||
|
||||
Long getAppid();
|
||||
|
||||
String getStatus();
|
||||
|
||||
Long getPlaytimeMinutes();
|
||||
|
||||
Long getPlaytime2Weeks();
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* A game the user removed and can restore.
|
||||
*/
|
||||
public record RemovedGame(long appid, String name, String imageUrl, GameSource source, String platform, Instant removedAt) {}
|
||||
@@ -27,6 +27,10 @@ public record SyncChange(SyncChangeKind kind, long appid, Integer amount, GameSt
|
||||
return new SyncChange(SyncChangeKind.STATUS, appid, null, from, to);
|
||||
}
|
||||
|
||||
public static SyncChange allUnlocked(long appid) {
|
||||
return new SyncChange(SyncChangeKind.ALL_UNLOCKED, appid, null, null, null);
|
||||
}
|
||||
|
||||
public static SyncChange wishlist(long appid) {
|
||||
return new SyncChange(SyncChangeKind.WISHLIST, appid, null, null, null);
|
||||
}
|
||||
|
||||
@@ -6,6 +6,8 @@ public enum SyncChangeKind {
|
||||
PLAYTIME,
|
||||
ACHIEVEMENTS,
|
||||
STATUS,
|
||||
// The sync unlocked the last achievement of a game that is not COMPLETED.
|
||||
ALL_UNLOCKED,
|
||||
// Only for followers' digests: never shown in the sync banner.
|
||||
WISHLIST
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* The play of one PlayStation title (V44).
|
||||
*
|
||||
* @param appid the game key the title is linked to; null when saving, the title is addressed by its key
|
||||
* @param platform "PS5" or "PS4"
|
||||
* @param lastPlayedAt may be null
|
||||
*/
|
||||
public record TitlePlay(String psnKey, Long appid, String platform, int playtimeMinutes, Instant lastPlayedAt) {}
|
||||
@@ -91,7 +91,7 @@ public class UserGame {
|
||||
@Column(name = "import_id")
|
||||
private Long importId;
|
||||
|
||||
/** From PlayStation; null for Steam rows. */
|
||||
/** From PlayStation or Steam; null when not known. */
|
||||
@Column(name = "last_played_at")
|
||||
private Instant lastPlayedAt;
|
||||
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.EnumType;
|
||||
import jakarta.persistence.Enumerated;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.Immutable;
|
||||
|
||||
import java.io.Serializable;
|
||||
|
||||
/**
|
||||
* A platform of a library entry, per edition of the entry: read-only view, see V43. {@code platform} is empty when the
|
||||
* edition has none.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "user_game_entry_platform")
|
||||
@IdClass(UserGameEntryPlatform.Key.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UserGameEntryPlatform {
|
||||
|
||||
public record Key(Long userId, Long appid, Long memberAppid, GameSource source, String platform) implements Serializable {}
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Id
|
||||
@Column(name = "member_appid")
|
||||
private Long memberAppid;
|
||||
|
||||
@Id
|
||||
@Enumerated(EnumType.STRING)
|
||||
@Column(name = "source")
|
||||
private GameSource source;
|
||||
|
||||
@Id
|
||||
@Column(name = "platform")
|
||||
private String platform;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.Immutable;
|
||||
|
||||
/**
|
||||
* The set total of an entry, see V45; hidden rows have none.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "user_game_entry_playtime")
|
||||
@IdClass(UserGameId.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UserGameEntryPlaytime {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Column(name = "playtime_minutes", nullable = false)
|
||||
private int playtimeMinutes;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package core_service.modules.library.domain;
|
||||
|
||||
import jakarta.persistence.Column;
|
||||
import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.IdClass;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.AccessLevel;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import org.hibernate.annotations.Immutable;
|
||||
|
||||
/**
|
||||
* A key in a user's set (game_link minus the keys the user split off): read-only view, see V42. Writes go through
|
||||
* {@code GameLinkRepository}.
|
||||
*/
|
||||
@Entity
|
||||
@Immutable
|
||||
@Table(name = "user_game_set")
|
||||
@IdClass(UserGameId.class)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
public class UserGameSetMember {
|
||||
|
||||
@Id
|
||||
@Column(name = "user_id")
|
||||
private Long userId;
|
||||
|
||||
@Id
|
||||
@Column(name = "appid")
|
||||
private Long appid;
|
||||
|
||||
@Column(name = "set_id")
|
||||
private Long setId;
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import java.util.List;
|
||||
|
||||
/**
|
||||
* The daily pile size per user, table backlog_snapshot. Counts listed games only, like {@link UserGameRepository#LISTED}, and a linked set once (V28).
|
||||
* Never played is judged by the set total (V45), like {@link UserGameRepository#countListedNeverPlayed}.
|
||||
*/
|
||||
@Repository
|
||||
@RequiredArgsConstructor
|
||||
@@ -19,11 +20,12 @@ public class BacklogSnapshotRepository {
|
||||
|
||||
private static final String PILE_COLUMNS = """
|
||||
count(*) filter (where ug.status = 'BACKLOG' and cg.appid is null) as backlog_games,
|
||||
count(*) filter (where ug.playtime_minutes = 0) as never_played,
|
||||
count(*) filter (where ep.playtime_minutes = 0) as never_played,
|
||||
coalesce(sum(t.normally_minutes) filter (where ug.status = 'BACKLOG' and cg.appid is null), 0) as backlog_minutes,
|
||||
count(*) as listed_games
|
||||
from counted_user_game ug left join game_time_to_beat t on t.appid = ug.appid
|
||||
left join coming_game cg on cg.appid = ug.appid
|
||||
left join user_game_entry_playtime ep on ep.user_id = ug.user_id and ep.appid = ug.appid
|
||||
""";
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
Loaded 100 of 215 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user