Public Access
Compare commits
38
Commits
867dc4c4d1
..
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 | ||
|
|
0ddb345a41 | ||
|
|
f5ca7237a9 | ||
|
|
7ac887ed5a | ||
|
|
e87f40bba0 | ||
|
|
117ff61730 | ||
|
|
8dd398321c |
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,17 @@
|
||||
[
|
||||
{
|
||||
"id": 900003,
|
||||
"name": "Mega Man Legacy Collection",
|
||||
"first_release_date": 1451520000,
|
||||
"cover": {
|
||||
"image_id": "co1r7f"
|
||||
},
|
||||
"platforms": [
|
||||
{
|
||||
"name": "PlayStation 4",
|
||||
"abbreviation": "PS4"
|
||||
}
|
||||
],
|
||||
"total_rating_count": 300
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,21 @@
|
||||
[
|
||||
{
|
||||
"id": 900002,
|
||||
"name": "Ghost of Tsushima",
|
||||
"first_release_date": 1595548800,
|
||||
"cover": {
|
||||
"image_id": "co1r7f"
|
||||
},
|
||||
"platforms": [
|
||||
{
|
||||
"name": "PlayStation 4",
|
||||
"abbreviation": "PS4"
|
||||
},
|
||||
{
|
||||
"name": "PlayStation 5",
|
||||
"abbreviation": "PS5"
|
||||
}
|
||||
],
|
||||
"total_rating_count": 2000
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,17 @@
|
||||
[
|
||||
{
|
||||
"id": 900001,
|
||||
"name": "Ghost of Yōtei",
|
||||
"first_release_date": 1760054400,
|
||||
"cover": {
|
||||
"image_id": "co1r7f"
|
||||
},
|
||||
"platforms": [
|
||||
{
|
||||
"name": "PlayStation 5",
|
||||
"abbreviation": "PS5"
|
||||
}
|
||||
],
|
||||
"total_rating_count": 800
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,81 @@
|
||||
{
|
||||
"titles": [
|
||||
{
|
||||
"titleId": "PPSA26344_00",
|
||||
"name": "Ghost of Yōtei",
|
||||
"category": "ps5_native_game",
|
||||
"service": "none(purchased)",
|
||||
"playCount": 31,
|
||||
"playDuration": "PT65H9M15S",
|
||||
"imageUrl": "https://image.api.playstation.com/local/ghost-of-yotei.png",
|
||||
"firstPlayedDateTime": "2025-10-03T18:20:11.000Z",
|
||||
"lastPlayedDateTime": "2026-10-01T20:41:07.000Z",
|
||||
"concept": {
|
||||
"id": 10003923,
|
||||
"titleIds": ["PPSA26344_00"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titleId": "CUSA02521_00",
|
||||
"name": "Mega Man Legacy Collection™",
|
||||
"category": "ps4_game",
|
||||
"service": "none(purchased)",
|
||||
"playCount": 9,
|
||||
"playDuration": "PT8H30M28S",
|
||||
"imageUrl": "https://image.api.playstation.com/local/mega-man-legacy.png",
|
||||
"firstPlayedDateTime": "2017-01-14T09:02:51.000Z",
|
||||
"lastPlayedDateTime": "2024-03-02T16:10:30.000Z",
|
||||
"concept": {
|
||||
"id": 202697,
|
||||
"titleIds": ["CUSA02521_00", "CUSA02522_00"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titleId": "PPSA03208_00",
|
||||
"name": "Ghost of Tsushima DIRECTOR'S CUT (PS5)",
|
||||
"category": "ps5_native_game",
|
||||
"service": "none(purchased)",
|
||||
"playCount": 22,
|
||||
"playDuration": "PT64H3S",
|
||||
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
|
||||
"firstPlayedDateTime": "2021-08-20T19:44:02.000Z",
|
||||
"lastPlayedDateTime": "2023-12-11T21:15:48.000Z",
|
||||
"concept": {
|
||||
"id": 235227,
|
||||
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titleId": "CUSA17722_00",
|
||||
"name": "Ghost of Tsushima",
|
||||
"category": "ps4_game",
|
||||
"service": "none(purchased)",
|
||||
"playCount": 3,
|
||||
"playDuration": "PT1H30M",
|
||||
"imageUrl": "https://image.api.playstation.com/local/ghost-of-tsushima.png",
|
||||
"firstPlayedDateTime": "2020-07-17T10:00:00.000Z",
|
||||
"lastPlayedDateTime": "2020-07-19T12:00:00.000Z",
|
||||
"concept": {
|
||||
"id": 235227,
|
||||
"titleIds": ["PPSA03208_00", "CUSA17722_00", "CUSA17723_00"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"titleId": "PPSA00000_00",
|
||||
"name": "Monster Hunter Wilds",
|
||||
"category": "ps5_native_game",
|
||||
"service": "none(purchased)",
|
||||
"playCount": 5,
|
||||
"playDuration": "PT12H",
|
||||
"imageUrl": "https://image.api.playstation.com/local/monster-hunter-wilds.png",
|
||||
"firstPlayedDateTime": "2025-03-01T10:00:00.000Z",
|
||||
"lastPlayedDateTime": "2025-03-09T22:00:00.000Z",
|
||||
"concept": {
|
||||
"id": 10000001,
|
||||
"titleIds": ["PPSA00000_00"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"totalItemCount": 5,
|
||||
"nextOffset": null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"access_token": "local-access",
|
||||
"token_type": "bearer",
|
||||
"expires_in": 3599,
|
||||
"scope": "psn:mobile.v2.core psn:clientapp",
|
||||
"id_token": "local-id",
|
||||
"refresh_token": "local-refresh",
|
||||
"refresh_token_expires_in": 863999
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{
|
||||
"trophySetVersion": "01.00",
|
||||
"hasTrophyGroups": true,
|
||||
"trophies": [
|
||||
{
|
||||
"trophyId": 0,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "platinum",
|
||||
"trophyName": "Legend of Yōtei",
|
||||
"trophyDetail": "Earn all other trophies.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 1,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "gold",
|
||||
"trophyName": "Wolf of Ezo",
|
||||
"trophyDetail": "Finish the story.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 2,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "silver",
|
||||
"trophyName": "Lone Blade",
|
||||
"trophyDetail": "Win a duel.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 3,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"trophyName": "First Steps",
|
||||
"trophyDetail": "Leave the village.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t3.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 4,
|
||||
"trophyHidden": true,
|
||||
"trophyType": "bronze",
|
||||
"trophyName": "Secret Name",
|
||||
"trophyDetail": "Secret detail text.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t4.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 5,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"trophyName": "Island Trial",
|
||||
"trophyDetail": "Finish the DLC trial.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t5.png",
|
||||
"trophyGroupId": "001"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"trophySetVersion": "01.00",
|
||||
"hasTrophyGroups": false,
|
||||
"trophies": [
|
||||
{
|
||||
"trophyId": 0,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"trophyName": "Blue Bomber",
|
||||
"trophyDetail": "Start the game.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t0.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 1,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"trophyName": "Rock On",
|
||||
"trophyDetail": "Beat Mega Man 1.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t1.png",
|
||||
"trophyGroupId": "default"
|
||||
},
|
||||
{
|
||||
"trophyId": 2,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "silver",
|
||||
"trophyName": "Robot Master",
|
||||
"trophyDetail": "Beat Mega Man 2.",
|
||||
"trophyIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/t2.png",
|
||||
"trophyGroupId": "default"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"trophySetVersion": "01.00",
|
||||
"hasTrophyGroups": true,
|
||||
"trophies": [
|
||||
{
|
||||
"trophyId": 0,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "platinum",
|
||||
"earned": true,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "2.1",
|
||||
"earnedDateTime": "2026-09-20T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"trophyId": 1,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "gold",
|
||||
"earned": true,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "30.5",
|
||||
"earnedDateTime": "2026-09-21T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"trophyId": 2,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "silver",
|
||||
"earned": true,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "55.0",
|
||||
"earnedDateTime": "2026-09-22T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"trophyId": 3,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"earned": true,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "90.2",
|
||||
"earnedDateTime": "2026-09-23T18:00:00Z"
|
||||
},
|
||||
{
|
||||
"trophyId": 4,
|
||||
"trophyHidden": true,
|
||||
"trophyType": "bronze",
|
||||
"earned": false,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "1.3"
|
||||
},
|
||||
{
|
||||
"trophyId": 5,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"earned": false,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "12.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"trophySetVersion": "01.00",
|
||||
"hasTrophyGroups": false,
|
||||
"trophies": [
|
||||
{
|
||||
"trophyId": 0,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"earned": false,
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "40.0"
|
||||
},
|
||||
{
|
||||
"trophyId": 1,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "bronze",
|
||||
"earned": true,
|
||||
"earnedDateTime": "2024-03-02T16:10:30Z",
|
||||
"trophyRare": 3,
|
||||
"trophyEarnedRate": "25.5"
|
||||
},
|
||||
{
|
||||
"trophyId": 2,
|
||||
"trophyHidden": false,
|
||||
"trophyType": "silver",
|
||||
"earned": false,
|
||||
"trophyRare": 2,
|
||||
"trophyEarnedRate": "10.0"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"titles": [
|
||||
{
|
||||
"npTitleId": "PPSA26344_00",
|
||||
"trophyTitles": [
|
||||
{
|
||||
"npServiceName": "trophy2",
|
||||
"npCommunicationId": "NPWR90001_00",
|
||||
"trophySetVersion": "01.00",
|
||||
"trophyTitleName": "Ghost of Yōtei",
|
||||
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
|
||||
"trophyTitlePlatform": "PS5",
|
||||
"hasTrophyGroups": true,
|
||||
"definedTrophies": {
|
||||
"bronze": 3,
|
||||
"silver": 1,
|
||||
"gold": 1,
|
||||
"platinum": 1
|
||||
},
|
||||
"progress": 66,
|
||||
"earnedTrophies": {
|
||||
"bronze": 1,
|
||||
"silver": 1,
|
||||
"gold": 1,
|
||||
"platinum": 1
|
||||
},
|
||||
"hiddenFlag": false,
|
||||
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"totalItemCount": 4,
|
||||
"nextOffset": null,
|
||||
"trophyTitles": [
|
||||
{
|
||||
"npServiceName": "trophy2",
|
||||
"npCommunicationId": "NPWR90001_00",
|
||||
"trophySetVersion": "01.00",
|
||||
"trophyTitleName": "Ghost of Yōtei",
|
||||
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ghost-set.png",
|
||||
"trophyTitlePlatform": "PS5",
|
||||
"hasTrophyGroups": true,
|
||||
"definedTrophies": {
|
||||
"bronze": 3,
|
||||
"silver": 1,
|
||||
"gold": 1,
|
||||
"platinum": 1
|
||||
},
|
||||
"progress": 66,
|
||||
"earnedTrophies": {
|
||||
"bronze": 1,
|
||||
"silver": 1,
|
||||
"gold": 1,
|
||||
"platinum": 1
|
||||
},
|
||||
"hiddenFlag": false,
|
||||
"lastUpdatedDateTime": "2026-10-01T20:41:07Z"
|
||||
},
|
||||
{
|
||||
"npServiceName": "trophy",
|
||||
"npCommunicationId": "NPWR90002_00",
|
||||
"trophySetVersion": "01.00",
|
||||
"trophyTitleName": "Mega Man Legacy Collection",
|
||||
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/megaman-set.png",
|
||||
"trophyTitlePlatform": "PS4",
|
||||
"hasTrophyGroups": false,
|
||||
"definedTrophies": {
|
||||
"bronze": 2,
|
||||
"silver": 1,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"progress": 33,
|
||||
"earnedTrophies": {
|
||||
"bronze": 1,
|
||||
"silver": 0,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"hiddenFlag": false,
|
||||
"lastUpdatedDateTime": "2024-03-02T16:10:30Z"
|
||||
},
|
||||
{
|
||||
"npServiceName": "trophy",
|
||||
"npCommunicationId": "NPWR90003_00",
|
||||
"trophySetVersion": "01.00",
|
||||
"trophyTitleName": "Old PS3 Game",
|
||||
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/ps3-set.png",
|
||||
"trophyTitlePlatform": "PS3",
|
||||
"hasTrophyGroups": false,
|
||||
"definedTrophies": {
|
||||
"bronze": 5,
|
||||
"silver": 0,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"progress": 20,
|
||||
"earnedTrophies": {
|
||||
"bronze": 1,
|
||||
"silver": 0,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"hiddenFlag": false,
|
||||
"lastUpdatedDateTime": "2012-05-01T10:00:00Z"
|
||||
},
|
||||
{
|
||||
"npServiceName": "trophy",
|
||||
"npCommunicationId": "NPWR90004_00",
|
||||
"trophySetVersion": "01.00",
|
||||
"trophyTitleName": "Hidden By The User",
|
||||
"trophyTitleIconUrl": "https://psnobj.prod.dl.playstation.net/psnobj/local/hidden-set.png",
|
||||
"trophyTitlePlatform": "PS4",
|
||||
"hasTrophyGroups": false,
|
||||
"definedTrophies": {
|
||||
"bronze": 1,
|
||||
"silver": 0,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"progress": 0,
|
||||
"earnedTrophies": {
|
||||
"bronze": 0,
|
||||
"silver": 0,
|
||||
"gold": 0,
|
||||
"platinum": 0
|
||||
},
|
||||
"hiddenFlag": true,
|
||||
"lastUpdatedDateTime": "2020-01-01T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_B1",
|
||||
"percent": 81.0
|
||||
},
|
||||
{
|
||||
"name": "ACH_B2",
|
||||
"percent": 57.3
|
||||
},
|
||||
{
|
||||
"name": "ACH_B3",
|
||||
"percent": 39.8
|
||||
},
|
||||
{
|
||||
"name": "ACH_B4",
|
||||
"percent": 26.5
|
||||
},
|
||||
{
|
||||
"name": "ACH_B5",
|
||||
"percent": 23.6
|
||||
},
|
||||
{
|
||||
"name": "ACH_B6",
|
||||
"percent": 18.2
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_A1",
|
||||
"percent": 88.2
|
||||
},
|
||||
{
|
||||
"name": "ACH_A2",
|
||||
"percent": 52.7
|
||||
},
|
||||
{
|
||||
"name": "ACH_A3",
|
||||
"percent": 74.1
|
||||
},
|
||||
{
|
||||
"name": "ACH_A4",
|
||||
"percent": 31.4
|
||||
},
|
||||
{
|
||||
"name": "ACH_A5",
|
||||
"percent": 44.9
|
||||
},
|
||||
{
|
||||
"name": "ACH_A6",
|
||||
"percent": 36.0
|
||||
},
|
||||
{
|
||||
"name": "ACH_A7",
|
||||
"percent": 28.3
|
||||
},
|
||||
{
|
||||
"name": "ACH_A8",
|
||||
"percent": 4.6
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_F1",
|
||||
"percent": 91.2
|
||||
},
|
||||
{
|
||||
"name": "ACH_F2",
|
||||
"percent": 64.8
|
||||
},
|
||||
{
|
||||
"name": "ACH_F3",
|
||||
"percent": 48.5
|
||||
},
|
||||
{
|
||||
"name": "ACH_F4",
|
||||
"percent": 37.1
|
||||
},
|
||||
{
|
||||
"name": "ACH_F5",
|
||||
"percent": 22.4
|
||||
},
|
||||
{
|
||||
"name": "ACH_F6",
|
||||
"percent": 11.0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_E1",
|
||||
"percent": 83.4
|
||||
},
|
||||
{
|
||||
"name": "ACH_E2",
|
||||
"percent": 62.0
|
||||
},
|
||||
{
|
||||
"name": "ACH_E3",
|
||||
"percent": 44.3
|
||||
},
|
||||
{
|
||||
"name": "ACH_E4",
|
||||
"percent": 31.9
|
||||
},
|
||||
{
|
||||
"name": "ACH_E5",
|
||||
"percent": 27.5
|
||||
},
|
||||
{
|
||||
"name": "ACH_E6",
|
||||
"percent": 9.7
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_C1",
|
||||
"percent": 78.5
|
||||
},
|
||||
{
|
||||
"name": "ACH_C2",
|
||||
"percent": 47.2
|
||||
},
|
||||
{
|
||||
"name": "ACH_C3",
|
||||
"percent": 33.6
|
||||
},
|
||||
{
|
||||
"name": "ACH_C4",
|
||||
"percent": 35.4
|
||||
},
|
||||
{
|
||||
"name": "ACH_C5",
|
||||
"percent": 41.0
|
||||
},
|
||||
{
|
||||
"name": "ACH_C6",
|
||||
"percent": 12.9
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"achievementpercentages": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_D1",
|
||||
"percent": 70.3
|
||||
},
|
||||
{
|
||||
"name": "ACH_D2",
|
||||
"percent": 25.8
|
||||
},
|
||||
{
|
||||
"name": "ACH_D3",
|
||||
"percent": 30.2
|
||||
},
|
||||
{
|
||||
"name": "ACH_D4",
|
||||
"percent": 15.6
|
||||
},
|
||||
{
|
||||
"name": "ACH_D5",
|
||||
"percent": 12.1
|
||||
},
|
||||
{
|
||||
"name": "ACH_D6",
|
||||
"percent": 8.4
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"response": {
|
||||
"game_count": 8,
|
||||
"game_count": 30,
|
||||
"games": [
|
||||
{
|
||||
"appid": 620,
|
||||
@@ -8,7 +8,8 @@
|
||||
"playtime_forever": 1450,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1787300000
|
||||
},
|
||||
{
|
||||
"appid": 1145360,
|
||||
@@ -16,7 +17,8 @@
|
||||
"playtime_forever": 3200,
|
||||
"playtime_2weeks": 240,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790500000
|
||||
},
|
||||
{
|
||||
"appid": 413150,
|
||||
@@ -24,7 +26,8 @@
|
||||
"playtime_forever": 5400,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1788900000
|
||||
},
|
||||
{
|
||||
"appid": 427520,
|
||||
@@ -32,7 +35,8 @@
|
||||
"playtime_forever": 12000,
|
||||
"playtime_2weeks": 600,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790900000
|
||||
},
|
||||
{
|
||||
"appid": 504230,
|
||||
@@ -40,7 +44,8 @@
|
||||
"playtime_forever": 800,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1789200000
|
||||
},
|
||||
{
|
||||
"appid": 367520,
|
||||
@@ -56,7 +61,8 @@
|
||||
"playtime_forever": 780,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1786900000
|
||||
},
|
||||
{
|
||||
"appid": 892970,
|
||||
@@ -64,6 +70,198 @@
|
||||
"playtime_forever": 90,
|
||||
"playtime_2weeks": 90,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790700000
|
||||
},
|
||||
{
|
||||
"appid": 1245620,
|
||||
"name": "ELDEN RING",
|
||||
"playtime_forever": 6100,
|
||||
"playtime_2weeks": 900,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790000000
|
||||
},
|
||||
{
|
||||
"appid": 1091500,
|
||||
"name": "Cyberpunk 2077",
|
||||
"playtime_forever": 4300,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1787900000
|
||||
},
|
||||
{
|
||||
"appid": 292030,
|
||||
"name": "The Witcher 3: Wild Hunt",
|
||||
"playtime_forever": 5200,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1789800000
|
||||
},
|
||||
{
|
||||
"appid": 1086940,
|
||||
"name": "Baldur's Gate 3",
|
||||
"playtime_forever": 2800,
|
||||
"playtime_2weeks": 300,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790150000
|
||||
},
|
||||
{
|
||||
"appid": 730,
|
||||
"name": "Counter-Strike 2",
|
||||
"playtime_forever": 1900,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1788600000
|
||||
},
|
||||
{
|
||||
"appid": 570,
|
||||
"name": "Dota 2",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 1174180,
|
||||
"name": "Red Dead Redemption 2",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 105600,
|
||||
"name": "Terraria",
|
||||
"playtime_forever": 2600,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1789300000
|
||||
},
|
||||
{
|
||||
"appid": 294100,
|
||||
"name": "RimWorld",
|
||||
"playtime_forever": 3900,
|
||||
"playtime_2weeks": 120,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790300000
|
||||
},
|
||||
{
|
||||
"appid": 632470,
|
||||
"name": "Disco Elysium - The Final Cut",
|
||||
"playtime_forever": 1500,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1788100000
|
||||
},
|
||||
{
|
||||
"appid": 548430,
|
||||
"name": "Deep Rock Galactic",
|
||||
"playtime_forever": 700,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790000000
|
||||
},
|
||||
{
|
||||
"appid": 646570,
|
||||
"name": "Slay the Spire",
|
||||
"playtime_forever": 2200,
|
||||
"playtime_2weeks": 60,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790450000
|
||||
},
|
||||
{
|
||||
"appid": 2379780,
|
||||
"name": "Balatro",
|
||||
"playtime_forever": 1100,
|
||||
"playtime_2weeks": 420,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790600000
|
||||
},
|
||||
{
|
||||
"appid": 1794680,
|
||||
"name": "Vampire Survivors",
|
||||
"playtime_forever": 450,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790700000
|
||||
},
|
||||
{
|
||||
"appid": 268910,
|
||||
"name": "Cuphead",
|
||||
"playtime_forever": 900,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1787600000
|
||||
},
|
||||
{
|
||||
"appid": 814380,
|
||||
"name": "Sekiro: Shadows Die Twice",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 374320,
|
||||
"name": "DARK SOULS III",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 1057090,
|
||||
"name": "Ori and the Will of the Wisps",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 275850,
|
||||
"name": "No Man's Sky",
|
||||
"playtime_forever": 120,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1790200000
|
||||
},
|
||||
{
|
||||
"appid": 526870,
|
||||
"name": "Satisfactory",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
},
|
||||
{
|
||||
"appid": 1332010,
|
||||
"name": "Stray",
|
||||
"playtime_forever": 330,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true,
|
||||
"rtime_last_played": 1789000000
|
||||
},
|
||||
{
|
||||
"appid": 108600,
|
||||
"name": "Project Zomboid",
|
||||
"playtime_forever": 0,
|
||||
"playtime_2weeks": null,
|
||||
"img_icon_url": "",
|
||||
"has_community_visible_stats": true
|
||||
}
|
||||
]
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "Cyberpunk 2077",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_B1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_B2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_B3",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786518400
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_B4",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786777600
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_B5",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1787036800
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_B6",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "ELDEN RING",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_A1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A3",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786518400
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A4",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A5",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A6",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A7",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_A8",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "Stray",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_F1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_F2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_F3",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_F4",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_F5",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_F6",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "Cuphead",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_E1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_E2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_E3",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786518400
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_E4",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786777600
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_E5",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1787036800
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_E6",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "Disco Elysium - The Final Cut",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_C1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_C2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_C3",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786518400
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_C4",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786777600
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_C5",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1787036800
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_C6",
|
||||
"achieved": 0,
|
||||
"unlocktime": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"playerstats": {
|
||||
"steamID": "76561198000000001",
|
||||
"gameName": "Slay the Spire",
|
||||
"success": true,
|
||||
"achievements": [
|
||||
{
|
||||
"apiname": "ACH_D1",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786000000
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_D2",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786259200
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_D3",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786518400
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_D4",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1786777600
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_D5",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1787036800
|
||||
},
|
||||
{
|
||||
"apiname": "ACH_D6",
|
||||
"achieved": 1,
|
||||
"unlocktime": 1787296000
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "Cyberpunk 2077",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_B1",
|
||||
"displayName": "Street Kid",
|
||||
"description": "Get through the opening job.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_B2",
|
||||
"displayName": "Chrome Fan",
|
||||
"description": "Install five implants.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_B3",
|
||||
"displayName": "Fast Driver",
|
||||
"description": "Win a street race.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_B4",
|
||||
"displayName": "Netrunner",
|
||||
"description": "Hack ten enemies in one fight.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_B5",
|
||||
"displayName": "Final Run",
|
||||
"description": "Complete the main story.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_B6",
|
||||
"displayName": "Rich Merc",
|
||||
"description": "Hold a big pile of eddies.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1091500/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "ELDEN RING",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_A1",
|
||||
"displayName": "Rune Bearer",
|
||||
"description": "Pick up your first rune from the grace.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A2",
|
||||
"displayName": "Margit Down",
|
||||
"description": "Defeat the warden of the gate.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A3",
|
||||
"displayName": "Roundtable Guest",
|
||||
"description": "Visit the hold between worlds.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A4",
|
||||
"displayName": "Smith Friend",
|
||||
"description": "Upgrade a weapon to a high level.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A5",
|
||||
"displayName": "Mounted Hunter",
|
||||
"description": "Slay a foe from horseback.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A6",
|
||||
"displayName": "Great Rune",
|
||||
"description": "Restore a great rune.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A7",
|
||||
"displayName": "Deep Delver",
|
||||
"description": "Explore a deep underground city.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_A8",
|
||||
"displayName": "Lord of Lands",
|
||||
"description": "Gather every legendary talisman.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1245620/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "Stray",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_F1",
|
||||
"displayName": "Cat Nap",
|
||||
"description": "Sleep in a sunny spot.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_F2",
|
||||
"displayName": "Rooftop Walker",
|
||||
"description": "Cross the roofs of the old slum.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_F3",
|
||||
"displayName": "Robot Friend",
|
||||
"description": "Make a friend of a small robot.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_F4",
|
||||
"displayName": "Meow Choir",
|
||||
"description": "Meow in front of ten robots.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_F5",
|
||||
"displayName": "Fish Lover",
|
||||
"description": "Find a cold bowl of fish.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_F6",
|
||||
"displayName": "Secret Paw",
|
||||
"description": "Open every hidden door.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/1332010/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "Cuphead",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_E1",
|
||||
"displayName": "Contract Signed",
|
||||
"description": "Take the deal at the casino.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_E2",
|
||||
"displayName": "Run and Gun",
|
||||
"description": "Pass a platform stage.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_E3",
|
||||
"displayName": "Boss Rush",
|
||||
"description": "Defeat five bosses.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_E4",
|
||||
"displayName": "Devil Beaten",
|
||||
"description": "Complete the main story.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_E5",
|
||||
"displayName": "Coin Collector",
|
||||
"description": "Find every coin of a world.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_E6",
|
||||
"displayName": "Perfect Parry",
|
||||
"description": "Parry twelve pink shots in a row.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/268910/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "Disco Elysium - The Final Cut",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_C1",
|
||||
"displayName": "Fresh Case",
|
||||
"description": "Wake up in the hostel room.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_C2",
|
||||
"displayName": "Good Listener",
|
||||
"description": "Talk to every skill voice.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_C3",
|
||||
"displayName": "Thought Cabinet",
|
||||
"description": "Fill all slots of the cabinet.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_C4",
|
||||
"displayName": "Case Closed",
|
||||
"description": "Finish the main story.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_C5",
|
||||
"displayName": "Best Friend",
|
||||
"description": "Win the trust of your partner.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_C6",
|
||||
"displayName": "Sweet Tunes",
|
||||
"description": "Hear every song on the radio.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/632470/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
{
|
||||
"game": {
|
||||
"gameName": "Slay the Spire",
|
||||
"gameVersion": "1",
|
||||
"availableGameStats": {
|
||||
"achievements": [
|
||||
{
|
||||
"name": "ACH_D1",
|
||||
"displayName": "Spire Climber",
|
||||
"description": "Reach the top of the first act.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_D2",
|
||||
"displayName": "Card Hoarder",
|
||||
"description": "Own forty cards in one run.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_D3",
|
||||
"displayName": "Relic Fan",
|
||||
"description": "Hold ten relics in one run.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_D4",
|
||||
"displayName": "Silent Hunter",
|
||||
"description": "Win a run as the second hero.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_D5",
|
||||
"displayName": "Cold Heart",
|
||||
"description": "Win a run as the third hero.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
},
|
||||
{
|
||||
"name": "ACH_D6",
|
||||
"displayName": "Pure Deck",
|
||||
"description": "Win a run with a tiny deck.",
|
||||
"hidden": 0,
|
||||
"icon": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg",
|
||||
"icongray": "https://cdn.cloudflare.steamstatic.com/steam/apps/646570/capsule_184x69.jpg"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"urlPath": "/igdb/games",
|
||||
"bodyPatterns": [
|
||||
{
|
||||
"contains": "search \"Mega Man Legacy"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "igdb-game-search-psn-mega-man-legacy.json"
|
||||
},
|
||||
"priority": 4
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"urlPath": "/igdb/games",
|
||||
"bodyPatterns": [
|
||||
{
|
||||
"contains": "search \"Ghost of Tsushima"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "igdb-game-search-psn-tsushima.json"
|
||||
},
|
||||
"priority": 4
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"urlPath": "/igdb/games",
|
||||
"bodyPatterns": [
|
||||
{
|
||||
"contains": "search \"Ghost of Yōtei"
|
||||
}
|
||||
]
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "igdb-game-search-psn-yotei.json"
|
||||
},
|
||||
"priority": 4
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/auth/authorize"
|
||||
},
|
||||
"response": {
|
||||
"status": 302,
|
||||
"headers": {
|
||||
"Location": "com.scee.psxandroid.scecompcall://redirect/?error=invalid_grant"
|
||||
}
|
||||
},
|
||||
"priority": 10
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/auth/authorize",
|
||||
"headers": {
|
||||
"Cookie": {
|
||||
"contains": "npsso=local-npsso"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 302,
|
||||
"headers": {
|
||||
"Location": "com.scee.psxandroid.scecompcall://redirect/?code=local-code&cid=local"
|
||||
}
|
||||
},
|
||||
"priority": 5
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/gamelist/v2/users/me/titles"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-gamelist.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"urlPath": "/psn/auth/token"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-token.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophies-NPWR90001_00.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophies-NPWR90002_00.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90001_00/trophyGroups/all/trophies"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophies-earned-NPWR90001_00.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/users/me/npCommunicationIds/NPWR90002_00/trophyGroups/all/trophies"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophies-earned-NPWR90002_00.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/users/me/titles/trophyTitles"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophy-title-sets.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/psn/trophy/v1/users/me/trophyTitles"
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "psn-trophy-titles.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "1091500"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-1091500.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "1245620"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-1245620.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "1332010"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-1332010.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "268910"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-268910.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "632470"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-632470.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetGlobalAchievementPercentagesForApp/v2/",
|
||||
"queryParameters": {
|
||||
"gameid": {
|
||||
"equalTo": "646570"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-global-percentages-646570.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1091500"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-1091500.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1245620"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-1245620.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1332010"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-1332010.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "268910"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-268910.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "632470"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-632470.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetPlayerAchievements/v1/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "646570"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-player-achievements-646570.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1091500"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-1091500.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1245620"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-1245620.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "1332010"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-1332010.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "268910"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-268910.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "632470"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-632470.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"urlPath": "/steam/ISteamUserStats/GetSchemaForGame/v2/",
|
||||
"queryParameters": {
|
||||
"appid": {
|
||||
"equalTo": "646570"
|
||||
}
|
||||
}
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"headers": {
|
||||
"Content-Type": "application/json"
|
||||
},
|
||||
"bodyFileName": "steam-schema-646570.json"
|
||||
}
|
||||
}
|
||||
@@ -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,40 +1,79 @@
|
||||
# To Be Played – Core Service
|
||||
|
||||
Business service of **To Be Played**, a Steam backlog tracker. It syncs the user's Steam library, stores the backlog
|
||||
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
|
||||
Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||
by hand or imported from GOG Galaxy, Playnite, Grouvee and HowLongToBeat files. It syncs the user's Steam library and
|
||||
the linked PlayStation account, stores the backlog (status, rating, notes, priority), tracks playtime per day, syncs
|
||||
the wishlist and adds time-to-beat data from IGDB.
|
||||
|
||||
To Be Played is split into three repositories:
|
||||
To Be Played is split into four repositories:
|
||||
|
||||
| Repository | Role |
|
||||
|---|---|
|
||||
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
||||
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||
| ToBePlayed-Frontend | React single-page app |
|
||||
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||
|
||||
## Features
|
||||
|
||||
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
|
||||
sync. Resilience4j rate-limits every Steam call and tries the owned games up to three times (never again on a 4xx;
|
||||
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with
|
||||
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
|
||||
sync. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
|
||||
achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
|
||||
once with an empty fallback (`steamApiOnce`); store, release and global-percentage calls not at all. Caffeine caches
|
||||
player summaries (`steamPlayerSummary`), IGDB searches and the landing numbers, not game counts. A sync commits its
|
||||
claim (`RUNNING`, with `sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
|
||||
sync sees `RUNNING` and skips, a failed one stores `FAILED`. A run older than `app.library.running-stale-minutes`
|
||||
can be taken over; its own finish then changes nothing.
|
||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
||||
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||
- **Picker**: up to 10 backlog games for a time budget (IGDB normal time to beat) and a mood, mixed from four
|
||||
sources, one per slot in turn: a game sharing moods with a random one of the user's 5 most played games (the
|
||||
most shared moods first), the game waiting longest (oldest `firstSeenAt`), and a random one (HIGH priority first,
|
||||
then MEDIUM, then the rest), and a random one of the first 5 games of the user's own backlog order that pass the
|
||||
budget, mood and exclude filters (`NEXT_UP`). A source with nothing left gives its slot to a random game; no game appears twice.
|
||||
Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR` or `firstSeenAt` for `LONGEST`).
|
||||
- **Picker**: seven rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
|
||||
`NEXT_UP` (the first games of the user's own backlog order), `SERIES` (next in series: for each of the 6 most played
|
||||
IGDB series the user played, one backlog part, the parts released after the latest played part first), `SIMILAR`
|
||||
("because you played": for each of the 3 most played games the first backlog game sharing its moods, no game twice),
|
||||
`STUDIO` (for each of the 6 developers of the user's most played games, one backlog game with a credit of that
|
||||
company, developer credits first; publishers are never anchors, so a big publisher adds at most one card),
|
||||
`FRIENDS` (for each followed user with a public profile, one backlog game of the user that the friend finished or
|
||||
played 5 h or more, strongest signal first; a finish and playtime need the `SUMMARY` or `STATUS` profile section (`FINISHED` alone lists only 12
|
||||
games); `DROPPED`, hidden and removed games never count; the friend's current
|
||||
display name, never an id),
|
||||
`LONGEST` (oldest `firstSeenAt`) and `RANDOM` (the mood row: random, HIGH priority first, then MEDIUM, then the
|
||||
rest). The mood filters only the `RANDOM` row; budget and exclude filters apply to every row. A row with no game is
|
||||
left out. A game can show in more than one row. IGDB series (collections) and companies are stored per game
|
||||
(`game_credit`, `game_credit_check`) and refreshed with the other IGDB data; a scheduled backfill
|
||||
(`app.library.credit-backfill-*`) fetches them for existing games, 250 games per 30 s, so the two rows fill up over
|
||||
time after a deploy. Every game carries a `reason` (`source`, plus the anchor game for `SIMILAR`, `seriesName` for
|
||||
`SERIES`, `studioName` for `STUDIO`, `friendName`, `friendFinished` and `friendPlaytimeMinutes` for `FRIENDS` or `firstSeenAt` for `LONGEST`).
|
||||
Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
|
||||
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
|
||||
|
||||
```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
|
||||
@@ -46,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
|
||||
@@ -75,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
|
||||
@@ -86,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
|
||||
@@ -98,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.
|
||||
@@ -123,10 +192,15 @@ To Be Played is split into three repositories:
|
||||
header picture (`IStoreBrowseService/GetItems` with assets) goes to `steam_game.image_url`. When a few achievement
|
||||
icons do not load, the schema comes fresh from Steam; an empty answer keeps the stored one. A report that cannot be repaired is tried again on the next report, at most 3 times,
|
||||
then after 30 days.
|
||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
||||
feature is off and the rest works.
|
||||
- **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
|
||||
them story / extras / 100 %. Optional: without credentials the feature is off and the rest works.
|
||||
- **PlayStation link** (optional, unofficial API): the user pastes an NPSSO once, Core keeps only the encrypted refresh
|
||||
token (about 10 days). Every library sync then adds the played PS4/PS5 titles that match IGDB exactly, keeps the unsure
|
||||
ones for the import review, and updates playtime and last played date. The same sync reads the PS4/PS5 **trophy
|
||||
sets** of the matched games: they count as achievements (platinum = perfect game) in the detail page, stats, tiles,
|
||||
public profile, sync banner and follower digest. See "PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
|
||||
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
|
||||
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
|
||||
and average completion (the numbers and the lists count a linked set once, as its counted edition). Readable only
|
||||
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
|
||||
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
||||
default. A section the user
|
||||
@@ -144,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
|
||||
@@ -174,49 +248,70 @@ 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 |
|
||||
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
|
||||
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
|
||||
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt, reviewImportId}` and a sync starts, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
|
||||
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
|
||||
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
|
||||
| `POST` | `/api/library/sync` | JWT |
|
||||
| `GET` | `/api/library/games/{appid}` (detail; `refresh=false` answers from stored data and sets `achievementsStale` when a due Steam refresh was skipped; `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
|
||||
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
|
||||
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
|
||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` (each game has a `reason` object) | JWT |
|
||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
|
||||
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
||||
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` | JWT |
|
||||
| `GET` | `/api/library/achievements/summary` (Steam achievements and PlayStation trophies; one entry per game, platinum = perfect) | JWT |
|
||||
| `GET` | `/api/library/sync-status`, `/summary`, `/activity`, `/wishlist`, `/steam-profile`, `/games-count` (`canSync`: a Steam ID or a PlayStation link; `sources`: the sources of the user's games) | JWT |
|
||||
| `GET` | `/api/public/profiles/library` | profile ticket |
|
||||
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
|
||||
| `GET` | `/api/public/profiles/library/games/{appid}` (`{appid, name, imageUrl, status, playtimeMinutes, achievementsUnlocked, achievementsTotal, completionPercent}`, a field of a hidden section is null; `404 GAME_NOT_FOUND` for a hidden, removed or not owned game or when every field is hidden, `401 INVALID_TICKET`) | profile ticket |
|
||||
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
|
||||
| `GET` | `/api/stats/public` (+ games per platform, top genres, finished per year) | public |
|
||||
| `GET` | `/api/stats/owners?appid=&key=&arg=&page=` (who owns a game in the set behind a community number, `key` default `OWNED`: `listed` display names only, `listedTotal`, `privateCount`, `includesYou`, not cached, `400 BAD_ARG`) | login |
|
||||
| `GET` | `/api/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
|
||||
| `GET` | `/api/follows` (`{following: [{id, displayName}], 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 |
|
||||
@@ -229,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
|
||||
|
||||
@@ -255,9 +350,11 @@ flowchart LR
|
||||
Steam["steam_adapter"]
|
||||
Igdb["igdb_adapter"]
|
||||
Speedrun["speedrun_adapter"]
|
||||
Psn["psn_adapter"]
|
||||
LibService --> Steam
|
||||
LibService --> Igdb
|
||||
LibService --> Speedrun
|
||||
LibService --> Psn
|
||||
end
|
||||
Browser -->|"tbp_access cookie"| Security
|
||||
Rewards --> Db
|
||||
@@ -267,44 +364,50 @@ flowchart LR
|
||||
Igdb --> IgdbApi["IGDB API"]
|
||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
||||
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
|
||||
```
|
||||
|
||||
| Module | Content |
|
||||
|---|---|
|
||||
| `library` | Sync, backlog, manual games, imports, playtime history, wishlist, achievements, public profile data |
|
||||
| `library` | Sync (Steam and PSN), backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
|
||||
| `stats` | Public numbers for the landing page |
|
||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||
| `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 |
|
||||
| `speedrun_adapter` | speedrun.com client: game search by name, verified runs |
|
||||
| `psn_adapter` | PlayStation client (unofficial): NPSSO to tokens, refresh, played titles, trophy sets and lists |
|
||||
|
||||
`follow` and `inbox` read `account_name`, `public_user`, `blocked_user`, `deleted_user`, `sync_result`, `sync_change`,
|
||||
`user_game` and `user_game_status_change` in SQL; the sync tells `follow` through `shared.event.LibraryActivityEvent`.
|
||||
|
||||
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `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`. A module that moves to its own service takes these reads along as calls.
|
||||
reads `deleted_user` and `blocked_user`, and for the picker `follow`, `public_user` and `account_name`; `rewards` reads
|
||||
`user_game_set` (a set pays once). A module that moves to its own service takes these reads along as calls.
|
||||
The listed rule ("still owned, not hidden") is `UserGameRepository.LISTED` for JPQL and the view
|
||||
`listed_user_game` for native SQL; V24 says what a migration of `user_game` must do with the view. The numbers that
|
||||
count games once per linked set read `counted_user_game` (V28), built on `listed_user_game`: a migration that changes
|
||||
a column of `user_game` drops this view first, then `listed_user_game`.
|
||||
a column of `user_game` drops the views built on it first: `user_game_entry_playtime`, `user_game_entry_platform`, `user_game_entry_member`, `counted_user_game`, then `listed_user_game`.
|
||||
The two entry views (V43) map an entry (a counted row) to its editions and to their platforms: `user_game_entry_member`
|
||||
is the one place that does it (stats and the library read it); `user_game_entry_platform` adds each edition's source
|
||||
and platform and a PlayStation row for a `LINKED` or `STEAM_OWNED` title on that key.
|
||||
|
||||
Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain` (JPA entities) and `repo`
|
||||
(repositories). `src/test/java/core_service/architecture/ArchitectureTest.java` checks these rules on every build:
|
||||
|
||||
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter` and
|
||||
`speedrun_adapter`.
|
||||
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
|
||||
`speedrun_adapter` and `psn_adapter`.
|
||||
- No cycles between `modules`, `shared` and `configuration`.
|
||||
- `api` never uses `repo`, and `repo` never uses `api`.
|
||||
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
|
||||
@@ -318,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
|
||||
@@ -331,14 +436,25 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
||||
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
|
||||
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
|
||||
no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
||||
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
|
||||
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
|
||||
games, the library stays as it is.
|
||||
A game the user added by hand for PC, Steam, Mac or Linux (or with no platform), whose IGDB game is the IGDB
|
||||
game of a Steam appid the user owns for the first time (`game_edition`), becomes that Steam row. The row moves
|
||||
from minus its IGDB id to the appid and keeps status, rating, notes, priority, status history, backlog
|
||||
position, questions, set rows, PlayStation links and paid game rewards. Playtime and last played come from
|
||||
Steam. A hand-added game on another platform stays and joins the Steam game's set. A user with such a game
|
||||
gets the IGDB edition refresh before the save, so the mapping is known in time.
|
||||
5. `TimeToBeatService` and `MoodService` ask IGDB for missing or old time-to-beat, mood and genre data, for Steam
|
||||
games and manual games. Hidden games only get their playtime updated.
|
||||
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
|
||||
in the background. The achievement sync also fetches Steam's global unlock rates with each schema, and sets
|
||||
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
|
||||
|
||||
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
|
||||
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
|
||||
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
|
||||
|
||||
The frontend polls `GET /api/library/sync-status` until the run ends.
|
||||
|
||||
Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
|
||||
@@ -347,15 +463,21 @@ 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
|
||||
participant P as PsnSyncStep + PsnTrophyStep
|
||||
participant A as AchievementSyncService
|
||||
participant F as FollowDigestService
|
||||
participant D as Db
|
||||
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST)
|
||||
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)
|
||||
@@ -367,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
|
||||
@@ -386,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
|
||||
@@ -471,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
|
||||
|
||||
@@ -492,7 +674,9 @@ 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
|
||||
|
||||
@@ -517,12 +701,22 @@ A game the user adds by hand comes from IGDB (`ManualGameService`). Steam games
|
||||
|
||||
`user_game.source` (`GameSource`) says who owns the row. The sync never changes or removes a `MANUAL` row, with
|
||||
one exception: when the user later owns the game on Steam under the same appid, the sync takes the row over: it becomes
|
||||
`STEAM`, and status, rating and notes stay. Only `MANUAL` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||
`STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||
or removed (`DELETE /api/library/games/{appid}`, soft: it sets `removed_at`, the row keeps status, rating, notes
|
||||
and history) and restored (`POST /api/library/games/{appid}/restore`, see the diagram). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
|
||||
a second row when IGDB learns its Steam appid later and the user owns it on Steam; an auditor then merges the two
|
||||
(see "Merging duplicates").
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Listed: add / sync
|
||||
Listed --> Removed: DELETE /games/{appid} (MANUAL, PSN)
|
||||
Removed --> Listed: POST /games/{appid}/restore or Undo
|
||||
Removed: removed_at set, data kept
|
||||
Removed: PSN sync skips the row
|
||||
```
|
||||
|
||||
### Imports
|
||||
|
||||
Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one
|
||||
@@ -559,10 +753,83 @@ stateDiagram-v2
|
||||
FAILED --> UNDONE: DELETE /games (the games added before it failed)
|
||||
```
|
||||
|
||||
The undo deletes the games of the import that are still `MANUAL` (a game the Steam sync took over stays). A removed
|
||||
The undo deletes the games of the import that are still `MANUAL` or `PSN` (a game the Steam sync took over stays). A removed
|
||||
row the import brings back gets no `import_id`: it holds rating, notes and history from before, so the undo keeps it;
|
||||
`undo-check` counts those with a rating, notes, priority or a status change after the save.
|
||||
|
||||
### PlayStation link
|
||||
|
||||
`modules/psn_adapter` calls PlayStation's unofficial API (the public client of the PlayStation app, as the `psn-api`
|
||||
project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` holds the use cases:
|
||||
|
||||
- **Link** (`POST /api/library/psn-account`): the NPSSO (the user's `npsso` cookie from playstation.com) goes to
|
||||
`/authorize` (the redirect is not followed: `Redirect.NEVER`), the `code` in its `Location` header to `/token`.
|
||||
Only the **refresh token** is stored, in `user_psn_account`, sealed with AES-256-GCM (`SecretBox`, key
|
||||
`APP_PSN_TOKEN_KEY`, base64 of 32 bytes). The NPSSO, the code and the access token are never stored; no log line,
|
||||
answer or `toString` of a token type holds a secret. No key = the feature is off (`enabled: false`); a key of the
|
||||
wrong length stops Core at start. A changed key makes stored links unreadable: the sync skips PlayStation and the user links again.
|
||||
- **Expiry**: the refresh token lives about 10 days (`refresh_expires_at`). A refresh that returns the same
|
||||
refresh token keeps the stored expiry; a new one stores its own. The Settings page shows the date. The inbox gets one
|
||||
note `PSN_EXPIRING` when the link ends within `app.psn.expiry-warning-days` (2) and one note `PSN_EXPIRED` when it has
|
||||
ended or PlayStation refuses the refresh token (also an unreadable token). `user_psn_account.expiry_note` keeps what
|
||||
was written, so a note comes once per link; a new link resets it. The library module writes `inbox_note` by SQL, as
|
||||
the follow module does. An ended link makes the sync skip PlayStation until the user pastes a new NPSSO.
|
||||
- **Sync**: linking starts a sync (`LibrarySyncService.startAfterLink`). Inside the force-sync cooldown or while a sync runs, one sync per user is scheduled for the end of the cooldown (in memory, lost on restart; the next login sync covers it).
|
||||
After that the PSN step (`PsnSyncStep`) runs in every library sync (login, nightly, manual) after the Steam fetch.
|
||||
One refresh call, then `gamelist/v2` (200 per page, at most 1000 titles). `PsnTitles` cleans the names (no `™`/`®`, no
|
||||
PS4/PS5 suffix) and joins the PS4 and PS5 versions of one `concept.id` into one line (name and platform of the most
|
||||
played version, playtime summed, newest `lastPlayedDateTime`). `user_psn_title` remembers each title by its
|
||||
`psn_key` (`c<conceptId>`, else `t<titleId>`) as `LINKED` (with the library key) or `DISMISSED`:
|
||||
- A **new** title (not in the table, not in an open PSN import) is searched in IGDB. Same name and a version on the
|
||||
line's platform: `ManualGameService.addGame` as `PSN`, no review, an `ADDED` change. The same game under another
|
||||
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
|
||||
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
|
||||
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
|
||||
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
|
||||
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
|
||||
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
|
||||
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. The minutes a title grew since
|
||||
its stored play are booked on its last played day (Berlin), only when its last played moved; a title linked in this
|
||||
run is the start point and books nothing (V49 dropped the old wrong rows). A game the user removed stays
|
||||
`LINKED` and the sync does not add it again; its row stays removed, so the sync skips it until the user restores it:
|
||||
a restore takes the stored title play (`psn_title_play`) and the sync updates the row again. Flyway V46 gave games
|
||||
removed before (soft delete) a removed `PSN` row and turned a `MANUAL` row re-added on a `LINKED` key back to `PSN`. A game also owned on Steam stays one row (the Steam row wins).
|
||||
- The first run of a user (no `user_psn_title` yet) is a baseline like the first Steam sync: its adds are a count in
|
||||
`sync_result.imported_games`, not a list. PSN changes share the `sync_result` of the Steam part, so the banner and the
|
||||
follow digest need no change.
|
||||
- PlayStation and IGDB are called outside the sync transaction; the step catches every error, so PlayStation never
|
||||
fails the Steam part. A user with a PlayStation link and **no Steam ID** syncs too (`user_steam_sync.steam_id` is
|
||||
nullable): the Steam part is skipped, the same result, banner and digest apply.
|
||||
The client cannot start a `PSN` import with its own rows.
|
||||
- **Limits**: one limiter `psnApi` for the whole server (1 request / 4 s, taken per HTTP request). Titles bought but
|
||||
never started are not listed by PlayStation. A `PSN` row pays no rewards (no Steam proof), and a merge undo does not
|
||||
move it back. The nightly loop is sequential.
|
||||
- **Trophies** (V40, `PsnTrophyStep`, called at the end of `PsnSyncStep.run`, outside the sync transaction, never throws).
|
||||
It reuses the access token of the title part (`FetchResult.Titles` hides it in `toString`). Own tables
|
||||
`psn_trophy_set`, `psn_trophy`, `user_psn_trophy_set`, `user_psn_trophy` and the cache `psn_title_trophy_set`, so the
|
||||
Steam tables and the rewards (Steam only) stay untouched.
|
||||
- **Summary**: 1-2 calls `trophy/v1/users/me/trophyTitles` give every set with defined and earned counts,
|
||||
`trophySetVersion` and `lastUpdatedDateTime`. Hidden (`hiddenFlag`) sets, PS3/Vita-only sets and sets of unknown or
|
||||
DISMISSED titles are not stored. A summary row is saved for every mapped set. **Gains** (`ACHIEVEMENTS` changes) come
|
||||
from the earned total only; the first time a set is stored is a baseline.
|
||||
Only a **complete** summary (sets returned = the API's `totalItemCount`) removes the user's stored sets that are no
|
||||
longer in it (another account was linked); a partial one, after a broken page, removes nothing. Unlink deletes all of
|
||||
the user's trophy rows.
|
||||
- **Mapping**, two paths, the log line `PSN trophies user=… mapped by title id=N by name=M` shows which one worked.
|
||||
(1) Exact: `titles/trophyTitles?npTitleIds=` (5 ids per call) answers title id to set; cached globally, "no set" is
|
||||
asked again after 30 days. Not confirmed against the real service: a 4xx or an empty answer falls through to (2)
|
||||
Name + platform, same account only: the clean name equals that of exactly one linked title and its platform is in the
|
||||
set's platform list. Two games with the same name: the set stays unmapped. The set counts for the merge target of the game.
|
||||
- **Lists only when changed**: the global trophy list when the stored `list_version` differs from `trophySetVersion`;
|
||||
the user's earned list (also gives the rarity) when `list_updated_at` differs from `lastUpdatedDateTime`; a set with
|
||||
0 earned needs no earned call.
|
||||
- **Budget** `app.psn.trophy-calls-per-run` (30, about 2 min at 1 request / 4 s; the sync counts as stale after 5 min)
|
||||
covers title id lookups and lists. Newest sets first; the rest follow in later syncs (the detail page then says the
|
||||
list loads with the next sync). Three errors in a row stop the list calls, the summaries are still saved.
|
||||
- **Presentation**: `perfect()` = platinum or all earned; one entry per set, one per game for the counts (perfect set,
|
||||
else best percent). A hidden trophy that is not earned has no name and no description. Stored image URLs must be `https://`.
|
||||
A sync costs 2 calls (token, titles) plus the summary and the changed lists.
|
||||
|
||||
## Run locally
|
||||
|
||||
### Local development without keys (profile `local`)
|
||||
@@ -577,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:
|
||||
@@ -596,6 +863,11 @@ What the `local` profile does:
|
||||
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
|
||||
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
|
||||
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
|
||||
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The link starts
|
||||
a sync: Ghost of Yōtei and Mega Man Legacy Collection match exactly and are added by themselves. Ghost of Tsushima (PS4
|
||||
and PS5 version as one line, "DIRECTOR'S CUT" in the name) and Monster Hunter Wilds (IGDB's recording does not know it)
|
||||
wait in the import review (`/import`). The local profile has a fixed token key. The trophy stubs (Ghost of Yōtei PS5
|
||||
by title id, Mega Man PS4 by name, one PS3 and one hidden set) are fake `NPWR9…` ids in `local/wiremock`.
|
||||
- To see an IGDB link suggestion: add Hollow Knight by hand (game search, PS5). It shares IGDB's "same game" key with the
|
||||
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||
|
||||
@@ -613,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:
|
||||
|
||||
@@ -626,7 +899,25 @@ Configuration comes from environment variables:
|
||||
| `JWT_PUBLIC_KEY` | Ed25519 public key (Base64 of the X.509 DER bytes), used until the first JWKS load. Set this, `JWT_JWKS_URL`, or both |
|
||||
| `STEAM_API_KEY` | Steam Web API key |
|
||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
||||
| `DB_PASSWORD` | Local PostgreSQL password |
|
||||
| `APP_PSN_TOKEN_KEY` | Key for the stored PlayStation tokens, `openssl rand -base64 32` (optional; empty = PlayStation link off) |
|
||||
| `APP_FRONTEND_URL` | CORS origin, default the production host |
|
||||
| `APP_ACCOUNT_CLEANUP_DRY_RUN` | Default `true`: the purge and prune only count |
|
||||
| `APP_BUILD_COMMIT`, `APP_BUILD_TIME` | Feed `GET /api/version`; defaults `dev` and empty. CI passes `GIT_SHA` and `BUILD_TIME` as Docker build args |
|
||||
|
||||
### Request timing log
|
||||
|
||||
`RequestTimingFilter` writes one INFO line to stdout per API request: method, handler path pattern, status and
|
||||
duration, e.g. `GET /api/library/games 200 28 ms`. The pattern is `-` when no handler matched (e.g. a `401` before
|
||||
routing). The line never holds ids, query strings, user data or tokens. `/actuator/**` is skipped. An exception
|
||||
that leaves the filter chain is logged as `500`.
|
||||
|
||||
### Database connection settings
|
||||
|
||||
Every Core connection runs with `plan_cache_mode = force_generic_plan` and `prepareThreshold=1`
|
||||
(`spring.datasource.hikari` in `application.yml`). Planning was 94% of the Dashboard DB time and prod never reused a
|
||||
plan (few users, a new connection every 30 min). With a cached generic plan the Dashboard burst dropped from 443 to
|
||||
229 ms on 2 CPUs (#49). Cost: the first burst on fresh connections costs about 100 ms more (cold start: 1101 -> 1205
|
||||
ms), and a generic plan ignores parameter values.
|
||||
|
||||
## Tests
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
|
||||
/**
|
||||
* The two pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
||||
* The pools of {@link BackgroundPools}. A dropped task is a refresh of data with a TTL: the next view or sync
|
||||
* asks again.
|
||||
*/
|
||||
@Configuration
|
||||
@@ -43,4 +43,14 @@ public class AsyncConfig {
|
||||
executor.setThreadNamePrefix("sync-follow-up-");
|
||||
return executor;
|
||||
}
|
||||
|
||||
// One thread: speedrun.com has one rate limit and the queue has one drainer. The queue holds only drain triggers.
|
||||
@Bean(name = BackgroundPools.SPEEDRUN)
|
||||
public ThreadPoolTaskExecutor speedrunExecutor() {
|
||||
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
|
||||
executor.setCorePoolSize(1);
|
||||
executor.setMaxPoolSize(1);
|
||||
executor.setThreadNamePrefix("speedrun-");
|
||||
return executor;
|
||||
}
|
||||
}
|
||||
@@ -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,15 +19,15 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class AccountCleanupRepository {
|
||||
|
||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
||||
// 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_game", "game_link_request", "account_name", "game_import", "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.
|
||||
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||
"game_import", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
|
||||
"game_import", "user_psn_account", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
|
||||
|
||||
private final NamedParameterJdbcTemplate jdbc;
|
||||
|
||||
@@ -36,7 +36,8 @@ public class AccountCleanupRepository {
|
||||
}
|
||||
|
||||
/**
|
||||
* Also takes the users off the nightly sync list and ends their follows, both ways, without a note.
|
||||
* Also takes the users off the nightly sync list, ends their follows, both ways, without a note, and drops their
|
||||
* stored PlayStation token at once.
|
||||
*/
|
||||
public void addDeleted(Collection<Long> userIds) {
|
||||
if (userIds.isEmpty()) {
|
||||
@@ -48,6 +49,7 @@ public class AccountCleanupRepository {
|
||||
jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds));
|
||||
jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds));
|
||||
jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds));
|
||||
jdbc.update("delete from user_psn_account where user_id in (:ids)", ids(userIds));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,23 @@ public final class IgdbResponses {
|
||||
@JsonProperty("game_modes") java.util.List<Named> gameModes
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record IdNamed(@JsonProperty("id") Long id, @JsonProperty("name") String name) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record InvolvedCompany(
|
||||
@JsonProperty("company") IdNamed company,
|
||||
@JsonProperty("developer") Boolean developer,
|
||||
@JsonProperty("publisher") Boolean publisher
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Credits(
|
||||
@JsonProperty("id") Long id,
|
||||
@JsonProperty("collections") java.util.List<IdNamed> collections,
|
||||
@JsonProperty("involved_companies") java.util.List<InvolvedCompany> involvedCompanies
|
||||
) {}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
public record Edition(
|
||||
@JsonProperty("id") Long id,
|
||||
|
||||
@@ -12,6 +12,7 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.RestClient;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
@@ -22,12 +23,14 @@ import java.util.Comparator;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.TreeSet;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@@ -49,6 +52,17 @@ public class IgdbApiService {
|
||||
*/
|
||||
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
|
||||
|
||||
public record Series(long id, String name) {}
|
||||
|
||||
public record Company(long id, String name, boolean developer, boolean publisher) {}
|
||||
|
||||
/**
|
||||
* @param series IGDB collections, e.g. "Mass Effect"
|
||||
* @param companies only those that developed or published, one entry per company; both lists are empty when IGDB
|
||||
* has none
|
||||
*/
|
||||
public record Credits(List<Series> series, List<Company> companies) {}
|
||||
|
||||
/**
|
||||
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
|
||||
*/
|
||||
@@ -64,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;
|
||||
@@ -156,6 +173,65 @@ public class IgdbApiService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the series and companies of the Steam apps IGDB links to a game, empty lists when that game has none;
|
||||
* apps IGDB does not link are missing. An app linked to several games gets the credits of all of them. Empty when
|
||||
* the adapter is off or a request failed.
|
||||
*/
|
||||
public Optional<Map<Long, Credits>> getCredits(Collection<Long> steamAppids) {
|
||||
if (!isEnabled() || steamAppids.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<Long> appids = List.copyOf(steamAppids);
|
||||
Map<Long, Credits> result = new HashMap<>();
|
||||
for (int from = 0; from < appids.size(); from += BATCH) {
|
||||
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
||||
creditsOf(steamByIgdb.keySet()).forEach((igdbId, credits) ->
|
||||
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, credits, IgdbApiService::union)));
|
||||
}
|
||||
return Optional.of(result);
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with IGDB for credits", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Like {@link #getCredits}, but for IGDB game ids.
|
||||
*/
|
||||
public Optional<Map<Long, Credits>> getCreditsByGameIds(Collection<Long> igdbIds) {
|
||||
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||
return Optional.empty();
|
||||
}
|
||||
try {
|
||||
List<Long> ids = List.copyOf(igdbIds);
|
||||
Map<Long, Credits> result = new HashMap<>();
|
||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||
result.putAll(creditsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||
}
|
||||
return Optional.of(result);
|
||||
} catch (Exception e) {
|
||||
log.error("Error communicating with IGDB for credits", e);
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
private static Credits union(Credits a, Credits b) {
|
||||
Map<Long, Series> series = new LinkedHashMap<>();
|
||||
Stream.concat(a.series().stream(), b.series().stream()).forEach(s -> series.putIfAbsent(s.id(), s));
|
||||
return new Credits(List.copyOf(series.values()),
|
||||
byCompany(Stream.concat(a.companies().stream(), b.companies().stream())));
|
||||
}
|
||||
|
||||
// One entry per company, its roles OR-ed.
|
||||
private static List<Company> byCompany(Stream<Company> companies) {
|
||||
Map<Long, Company> byId = new LinkedHashMap<>();
|
||||
companies.forEach(c -> byId.merge(c.id(), c, (x, y) ->
|
||||
new Company(x.id(), x.name(), x.developer() || y.developer(), x.publisher() || y.publisher())));
|
||||
return List.copyOf(byId.values());
|
||||
}
|
||||
|
||||
private static GameTags union(GameTags a, GameTags b) {
|
||||
return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes()));
|
||||
}
|
||||
@@ -310,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();
|
||||
}
|
||||
}
|
||||
@@ -470,6 +559,44 @@ public class IgdbApiService {
|
||||
return result;
|
||||
}
|
||||
|
||||
private Map<Long, Credits> creditsOf(Collection<Long> igdbIds) {
|
||||
if (igdbIds.isEmpty()) {
|
||||
return Map.of();
|
||||
}
|
||||
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
||||
IgdbResponses.Credits[] games = query("/games",
|
||||
"fields collections.name,involved_companies.company.name,involved_companies.developer,"
|
||||
+ "involved_companies.publisher; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
||||
IgdbResponses.Credits[].class);
|
||||
Map<Long, Credits> result = new HashMap<>();
|
||||
for (IgdbResponses.Credits game : games) {
|
||||
if (game.id() != null && igdbIds.contains(game.id())) {
|
||||
result.put(game.id(), new Credits(seriesOf(game.collections()), companiesOf(game.involvedCompanies())));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static List<Series> seriesOf(List<IgdbResponses.IdNamed> collections) {
|
||||
return collections == null ? List.of() : collections.stream()
|
||||
.filter(IgdbApiService::hasName)
|
||||
.map(c -> new Series(c.id(), c.name().trim()))
|
||||
.toList();
|
||||
}
|
||||
|
||||
// Only developers and publishers; porting or supporting companies are dropped.
|
||||
private static List<Company> companiesOf(List<IgdbResponses.InvolvedCompany> involved) {
|
||||
return involved == null ? List.of() : byCompany(involved.stream()
|
||||
.filter(i -> hasName(i.company()))
|
||||
.map(i -> new Company(i.company().id(), i.company().name().trim(),
|
||||
Boolean.TRUE.equals(i.developer()), Boolean.TRUE.equals(i.publisher())))
|
||||
.filter(c -> c.developer() || c.publisher()));
|
||||
}
|
||||
|
||||
private static boolean hasName(IgdbResponses.IdNamed named) {
|
||||
return named != null && named.id() != null && named.name() != null && !named.name().isBlank();
|
||||
}
|
||||
|
||||
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
|
||||
if (igdbIds.isEmpty()) {
|
||||
return Map.of();
|
||||
@@ -491,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())) {
|
||||
@@ -504,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();
|
||||
}
|
||||
@@ -564,7 +705,8 @@ public class IgdbApiService {
|
||||
.uri(path)
|
||||
.header("Client-ID", clientId)
|
||||
.header("Authorization", "Bearer " + token)
|
||||
.contentType(MediaType.TEXT_PLAIN)
|
||||
// Without the charset the body goes out in ISO-8859-1 and a name like "Yōtei" arrives as "Y?tei".
|
||||
.contentType(new MediaType(MediaType.TEXT_PLAIN, StandardCharsets.UTF_8))
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -9,10 +9,12 @@ import java.util.List;
|
||||
public record InboxDto(List<Note> results, int unread) {
|
||||
|
||||
/**
|
||||
* @param kind DIGEST or VISIBILITY
|
||||
* @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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -2,5 +2,8 @@ package core_service.modules.inbox.domain;
|
||||
|
||||
public enum NoteKind {
|
||||
DIGEST,
|
||||
VISIBILITY
|
||||
VISIBILITY,
|
||||
/** About the reader's own PlayStation link; subject is the reader. */
|
||||
PSN_EXPIRING,
|
||||
PSN_EXPIRED
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -62,7 +62,8 @@ public class GameImportController {
|
||||
@PostMapping("/{id}/save")
|
||||
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
|
||||
@Valid @RequestBody ImportSaveRequest request) {
|
||||
return switch (importService.save(principal.getIdAsLong(), id, request.rows())) {
|
||||
return switch (importService.save(principal.getIdAsLong(), id, request.rows(),
|
||||
request.shownLineNos())) {
|
||||
case DONE -> ResponseEntity.accepted().build();
|
||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||
|
||||
@@ -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,9 +38,12 @@ 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;
|
||||
import core_service.modules.library.domain.StatGroup;
|
||||
import core_service.modules.library.domain.StatKey;
|
||||
import core_service.modules.library.domain.SyncChange;
|
||||
@@ -82,7 +87,6 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
import java.util.EnumSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
||||
@@ -96,10 +100,11 @@ public class LibraryController {
|
||||
private static final int DEFAULT_SIZE = 20;
|
||||
private static final int MIN_ACTIVITY_DAYS = 7;
|
||||
private static final int MAX_ACTIVITY_DAYS = 90;
|
||||
private static final int MAX_PICKER_GAMES = 10;
|
||||
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;
|
||||
@@ -118,15 +123,12 @@ public class LibraryController {
|
||||
@GetMapping("/games-count")
|
||||
public ResponseEntity<?> getGamesCount(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
Long userId = principal.getIdAsLong();
|
||||
boolean syncStarted = principal.steamIdText()
|
||||
.map(steamId -> syncService.syncIfStale(userId, steamId))
|
||||
.orElseGet(() -> {
|
||||
syncService.stopNightlySync(userId);
|
||||
return false;
|
||||
});
|
||||
String steamId = principal.steamIdText().orElse(null);
|
||||
boolean syncStarted = syncService.syncIfStale(userId, steamId);
|
||||
long count = queryService.countOwnedGames(userId);
|
||||
return ResponseEntity.ok(Map.of("count", count, "hasSteamId", principal.steamId() != null,
|
||||
"syncStarted", syncStarted));
|
||||
"canSync", syncService.canSync(userId, steamId), "syncStarted", syncStarted,
|
||||
"sources", queryService.listSources(userId)));
|
||||
}
|
||||
|
||||
@GetMapping("/games")
|
||||
@@ -139,18 +141,28 @@ 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();
|
||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
|
||||
|
||||
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;
|
||||
@@ -164,13 +176,12 @@ public class LibraryController {
|
||||
|
||||
@PostMapping("/sync")
|
||||
public ResponseEntity<?> forceSync(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
Optional<String> steamId = principal.steamIdText();
|
||||
if (steamId.isEmpty()) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID", "User has no Steam ID"));
|
||||
}
|
||||
try {
|
||||
syncService.forceSync(principal.getIdAsLong(), steamId.get());
|
||||
syncService.forceSync(principal.getIdAsLong(), principal.steamIdText().orElse(null));
|
||||
return ResponseEntity.accepted().body(Map.of("status", "started"));
|
||||
} catch (IllegalArgumentException e) {
|
||||
return ResponseEntity.badRequest().body(new ErrorResponse("NO_STEAM_ID",
|
||||
"User has no Steam ID or PlayStation link"));
|
||||
} catch (SyncCooldownException e) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||
.header(HttpHeaders.RETRY_AFTER, String.valueOf(e.getRetryAfterSeconds()))
|
||||
@@ -181,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));
|
||||
@@ -302,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) {
|
||||
@@ -347,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());
|
||||
}
|
||||
@@ -488,14 +522,13 @@ public class LibraryController {
|
||||
@RequestParam(required = false) Integer maxMinutes,
|
||||
@RequestParam(required = false) Mood mood,
|
||||
@RequestParam(required = false) List<Long> exclude,
|
||||
@RequestParam(defaultValue = "3") int count) {
|
||||
@RequestParam(required = false) PickSource category) {
|
||||
List<Long> excluded = exclude == null ? List.of()
|
||||
: exclude.stream().filter(Objects::nonNull).limit(MAX_PICKER_EXCLUDES).toList();
|
||||
int safeCount = Math.clamp(count, 1, MAX_PICKER_GAMES);
|
||||
Integer budget = maxMinutes != null && maxMinutes > 0 ? maxMinutes : null;
|
||||
Long userId = principal.getIdAsLong();
|
||||
principal.steamIdText().ifPresent(steamId -> syncService.syncIfStale(userId, steamId));
|
||||
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, safeCount));
|
||||
syncService.syncIfStale(userId, principal.steamIdText().orElse(null));
|
||||
return ResponseEntity.ok(queryService.pickBacklogGames(userId, budget, mood, excluded, category));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package core_service.modules.library.api;
|
||||
|
||||
import core_service.modules.library.api.dto.PsnAccountDto;
|
||||
import core_service.modules.library.api.dto.PsnLinkRequest;
|
||||
import core_service.modules.library.service.LibrarySyncService;
|
||||
import core_service.modules.library.service.PsnAccountService;
|
||||
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.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
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;
|
||||
|
||||
/**
|
||||
* The user's linked PlayStation account; linking starts a sync. No answer repeats the request.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/library/psn-account")
|
||||
@RequiredArgsConstructor
|
||||
public class PsnAccountController {
|
||||
|
||||
private static final ResponseEntity<?> PSN_OFF = error(HttpStatus.SERVICE_UNAVAILABLE, "PSN_OFF",
|
||||
"The PlayStation link is not set up on this server");
|
||||
private static final ResponseEntity<?> PSN_BUSY = error(HttpStatus.TOO_MANY_REQUESTS, "PSN_BUSY",
|
||||
"Another PlayStation request of yours is still running");
|
||||
private static final ResponseEntity<?> PSN_ERROR = error(HttpStatus.BAD_GATEWAY, "PSN_ERROR",
|
||||
"PlayStation did not answer; try again later");
|
||||
|
||||
private final PsnAccountService accountService;
|
||||
private final LibrarySyncService syncService;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<?> link(@AuthenticationPrincipal UserPrincipal principal,
|
||||
@Valid @RequestBody PsnLinkRequest request) {
|
||||
return switch (accountService.link(principal.getIdAsLong(), request.npsso())) {
|
||||
case PsnAccountService.LinkResult.Linked linked -> {
|
||||
syncService.startAfterLink(principal.getIdAsLong(), principal.steamIdText().orElse(null));
|
||||
yield ResponseEntity.ok(dto(linked.status()));
|
||||
}
|
||||
case PsnAccountService.LinkResult.Rejected ignored -> error(HttpStatus.BAD_REQUEST, "NPSSO_INVALID",
|
||||
"PlayStation did not accept the NPSSO; it may have expired");
|
||||
case PsnAccountService.LinkResult.Off ignored -> PSN_OFF;
|
||||
case PsnAccountService.LinkResult.Unavailable ignored -> PSN_ERROR;
|
||||
case PsnAccountService.LinkResult.Busy ignored -> PSN_BUSY;
|
||||
};
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public PsnAccountDto get(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
return dto(accountService.get(principal.getIdAsLong()));
|
||||
}
|
||||
|
||||
@DeleteMapping
|
||||
public ResponseEntity<Void> unlink(@AuthenticationPrincipal UserPrincipal principal) {
|
||||
accountService.unlink(principal.getIdAsLong());
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
private static PsnAccountDto dto(PsnAccountService.Status status) {
|
||||
return new PsnAccountDto(status.enabled(), status.linked(), status.linkedAt(), status.expiresAt(),
|
||||
status.reviewImportId());
|
||||
}
|
||||
|
||||
private static ResponseEntity<?> error(HttpStatus status, String code, String message) {
|
||||
return ResponseEntity.status(status).body(new ErrorResponse(code, message));
|
||||
}
|
||||
}
|
||||
@@ -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"));
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,20 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.AchievementKind;
|
||||
import core_service.modules.library.domain.TrophyType;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/**
|
||||
* One Steam achievement or PlayStation trophy. A hidden PlayStation trophy that is not earned has no name and no
|
||||
* description.
|
||||
*
|
||||
* @param apiName Steam: the api name; PSN: the trophy id
|
||||
* @param setId null for Steam; PSN: the npCommunicationId
|
||||
* @param trophyType PSN only
|
||||
* @param group PSN only: null for the base game, "001" and up for DLC
|
||||
* @param rarity percent of all players who have it; null when unknown
|
||||
*/
|
||||
public record AchievementDto(
|
||||
String apiName,
|
||||
String displayName,
|
||||
@@ -10,5 +23,10 @@ public record AchievementDto(
|
||||
String iconGrayUrl,
|
||||
boolean hidden,
|
||||
boolean unlocked,
|
||||
Instant unlockedAt
|
||||
Instant unlockedAt,
|
||||
AchievementKind kind,
|
||||
String setId,
|
||||
TrophyType trophyType,
|
||||
String group,
|
||||
Double rarity
|
||||
) {}
|
||||
@@ -0,0 +1,25 @@
|
||||
package core_service.modules.library.api.dto;
|
||||
|
||||
import core_service.modules.library.domain.AchievementKind;
|
||||
|
||||
/**
|
||||
* The achievements of a game come in sets: Steam's, and one per PlayStation trophy set.
|
||||
*
|
||||
* @param id "steam", or the npCommunicationId of the trophy set
|
||||
* @param platform PSN: PS5 or PS4; null for Steam
|
||||
* @param name PSN: the set's name; null for Steam
|
||||
* @param perfect all earned, or the platinum trophy
|
||||
* @param visible false when Steam hides the user's achievements
|
||||
* @param listed false until the sync has fetched the trophy list of the set
|
||||
*/
|
||||
public record AchievementSetDto(
|
||||
String id,
|
||||
AchievementKind kind,
|
||||
String platform,
|
||||
String name,
|
||||
int total,
|
||||
int unlocked,
|
||||
boolean perfect,
|
||||
boolean visible,
|
||||
boolean listed
|
||||
) {}
|
||||
@@ -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) {}
|
||||
Loaded 100 of 335 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user