Public Access
Compare commits
43
Commits
10b7dc2272
..
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 | ||
|
|
867dc4c4d1 | ||
|
|
2dba863c54 | ||
|
|
7747bc7b8b | ||
|
|
7b538b7f30 | ||
|
|
33b230ee91 |
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": {
|
"response": {
|
||||||
"game_count": 8,
|
"game_count": 30,
|
||||||
"games": [
|
"games": [
|
||||||
{
|
{
|
||||||
"appid": 620,
|
"appid": 620,
|
||||||
@@ -8,7 +8,8 @@
|
|||||||
"playtime_forever": 1450,
|
"playtime_forever": 1450,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1787300000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 1145360,
|
"appid": 1145360,
|
||||||
@@ -16,7 +17,8 @@
|
|||||||
"playtime_forever": 3200,
|
"playtime_forever": 3200,
|
||||||
"playtime_2weeks": 240,
|
"playtime_2weeks": 240,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790500000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 413150,
|
"appid": 413150,
|
||||||
@@ -24,7 +26,8 @@
|
|||||||
"playtime_forever": 5400,
|
"playtime_forever": 5400,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1788900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 427520,
|
"appid": 427520,
|
||||||
@@ -32,7 +35,8 @@
|
|||||||
"playtime_forever": 12000,
|
"playtime_forever": 12000,
|
||||||
"playtime_2weeks": 600,
|
"playtime_2weeks": 600,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1790900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 504230,
|
"appid": 504230,
|
||||||
@@ -40,7 +44,8 @@
|
|||||||
"playtime_forever": 800,
|
"playtime_forever": 800,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1789200000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 367520,
|
"appid": 367520,
|
||||||
@@ -56,7 +61,8 @@
|
|||||||
"playtime_forever": 780,
|
"playtime_forever": 780,
|
||||||
"playtime_2weeks": null,
|
"playtime_2weeks": null,
|
||||||
"img_icon_url": "",
|
"img_icon_url": "",
|
||||||
"has_community_visible_stats": true
|
"has_community_visible_stats": true,
|
||||||
|
"rtime_last_played": 1786900000
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"appid": 892970,
|
"appid": 892970,
|
||||||
@@ -64,6 +70,198 @@
|
|||||||
"playtime_forever": 90,
|
"playtime_forever": 90,
|
||||||
"playtime_2weeks": 90,
|
"playtime_2weeks": 90,
|
||||||
"img_icon_url": "",
|
"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
|
"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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-actuator</artifactId>
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
|
||||||
<groupId>io.micrometer</groupId>
|
|
||||||
<artifactId>micrometer-registry-prometheus</artifactId>
|
|
||||||
<scope>runtime</scope>
|
|
||||||
</dependency>
|
|
||||||
|
|
||||||
<!-- testing -->
|
<!-- testing -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|||||||
@@ -1,40 +1,106 @@
|
|||||||
# To Be Played – Core Service
|
# 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
|
Business service of **To Be Played**, a backlog tracker for your Steam and PlayStation libraries, plus games added
|
||||||
(status, rating, notes, priority), tracks playtime per day, syncs the wishlist and adds time-to-beat data from IGDB.
|
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 |
|
| Repository | Role |
|
||||||
|---|---|
|
|---|---|
|
||||||
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
| ToBePlayed-Auth | Users, login, settings, signs JWTs |
|
||||||
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
|
| **ToBePlayed-Core** (this one) | Steam library, backlog, wishlist, stats; verifies JWTs |
|
||||||
| ToBePlayed-Frontend | React single-page app |
|
| ToBePlayed-Frontend | React single-page app |
|
||||||
|
| ToBePlayed-Deployment | Docker Compose stack, env template, ROADMAP.md (history), plans |
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Steam sync**: owned games, playtime and achievements through the Steam Web API. Manual sync plus a nightly
|
- **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;
|
sync. Resilience4j rate-limits every Steam call (2 per second) and retries only where a repeat is safe: owned games,
|
||||||
the wishlist and the calls a page waits for once); Caffeine caches game counts and player summaries. A sync commits its claim (`RUNNING`, with
|
achievement schema and player achievements up to 3 times (`steamApi`, never on a 4xx); player summary and wishlist
|
||||||
`sync_started_at`), its writes and its finish apart, and calls Steam and IGDB outside any transaction: a second
|
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`
|
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.
|
can be taken over; its own finish then changes nothing.
|
||||||
- **Backlog**: status (`BACKLOG`, `PLAYING`, `FINISHED`, `COMPLETED` = 100 %, `DROPPED`), rating 1–5, notes, priority,
|
- **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.
|
filters and sorting. "Pile of Shame" summary with a random suggestion of what to play.
|
||||||
- **Picker**: up to 10 random backlog games for a time budget (IGDB normal time to beat) and a mood, HIGH priority
|
- **Picker**: seven rows of up to 3 backlog games each, for a time budget (IGDB normal time to beat), in this order:
|
||||||
first, then MEDIUM, then the rest. Games already shown can be excluded for a reroll. Moods `STORY`, `COOP`,
|
`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.
|
`CHILL`, `ACTION`, `SCARY` come from IGDB genres, themes and game modes; `SPEEDRUN` from speedrun.com.
|
||||||
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (and
|
|
||||||
a release year at most one year off, when IGDB knows it) with at least one verified run. The game detail returns
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
NEXT_UP --> Rows
|
||||||
|
SERIES --> Rows
|
||||||
|
SIMILAR --> Rows
|
||||||
|
STUDIO --> Rows
|
||||||
|
FRIENDS --> Rows
|
||||||
|
LONGEST --> Rows
|
||||||
|
RANDOM --> Rows
|
||||||
|
Rows["up to 3 games per row (ROW_SIZE), empty rows left out"] --> Filters["budget + exclude: every row; mood: RANDOM only"]
|
||||||
|
Filters --> Out["games + reason"]
|
||||||
|
```
|
||||||
|
- **Follow users (#27)**: a logged-in user follows people with a public profile by display name (`/api/follows`, at most
|
||||||
|
500, never an id in an answer; `GET /api/follows/players?q=` finds public profiles by name, rate-limited) and gets
|
||||||
|
one inbox note per followed user and batch (`/api/inbox`): a sync with its achievement run, or the hand changes of a
|
||||||
|
quiet 10 minutes. A note counts new games, achievement unlocks, status changes and Steam wishlist adds, and leaves
|
||||||
|
out what the followed user's profile hides (`public_user.sections`).
|
||||||
|
Follows end for good, with a note, when the followed user goes private, and without a note when the user is blocked or
|
||||||
|
deleted. Each note keeps one copy of the lines behind its counts (`inbox_note_detail`: games, achievement or trophy
|
||||||
|
names, status moves collapsed per game, wishlist adds; at most 20 achievements per game, the newest unlocks stand for
|
||||||
|
the new ones). `GET /api/inbox/{id}/details` shows them only while the reader still follows the subject, the subject
|
||||||
|
is public, else `404`. Rows of a hidden part or a hidden game are left out. The nightly job at 03:15 deletes notes
|
||||||
|
older than 90 days (`app.inbox.keep-days`); the list shows the newest 50. A note also carries `profileName` (the
|
||||||
|
subject's current name while the profile is public, else null), a detail line its `appid`, and each followed person
|
||||||
|
in `GET /api/follows` `lastActivityAt` (the newest digest note the caller got about them): the Frontend links the feed
|
||||||
|
to the game and to the profile view of that game. `GET /api/library/games/{appid}/preview` gives the catalog data of
|
||||||
|
any game (for "not in your library yet"), `GET /api/public/profiles/library/games/{appid}` one listed game of a public
|
||||||
|
profile, filtered by the ticket's sections; a hidden, removed or not owned game answers the same `404`.
|
||||||
|
- **Backlog order**: every listed `BACKLOG` game (not removed, not hidden) has a row in `backlog_order (user_id,
|
||||||
|
appid, position)`, kept by a database trigger on `user_game` like the status history: a game entering the backlog
|
||||||
|
goes to the bottom, a game leaving it loses its row. V34 seeded the first order (priority HIGH, MEDIUM, rest, then
|
||||||
|
date added). `PUT /api/library/backlog` replaces the whole order under a row lock; ids not in the backlog are
|
||||||
|
ignored and games not sent go after the sent ones. A merge keeps the better position of the two games. Coming
|
||||||
|
games are in the list; `released=true` leaves them out.
|
||||||
|
- **Coming games**: a game with a release date after today (Berlin date) or Steam "coming soon", and no playtime or
|
||||||
|
achievement of any user, is not picked and not on the pile (live count, new snapshots, pile stats, time to beat
|
||||||
|
sum). It keeps its status and shows `coming` / `comingOn`. Date: IGDB `first_release_date`, else Steam's store
|
||||||
|
(`GetItems` with `include_release`), else unknown (= released). View `coming_game`, table `game_release`; a
|
||||||
|
year-only IGDB date stays a placeholder until the real date comes (coming rows refresh daily).
|
||||||
|
- **Speedrun games**: a backlog game counts as a speedrun game when speedrun.com has a game with the same name (case,
|
||||||
|
punctuation, ™ and roman numerals ignored) and a release year at most one year off, when IGDB knows it, with at
|
||||||
|
least one verified run. An edition that finds nothing tries its IGDB parent game with the parent's year, else its
|
||||||
|
name without an edition suffix. Several same-name games and no year to decide: no link. The game detail returns
|
||||||
the speedrun.com link as `speedrunUrl`. speedrun.com has no Steam or IGDB link and no key, so one queue works for
|
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
|
- **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
|
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
|
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
|
overwrites stored data. Summary: unlocked total, perfect games, average completion over started games, the 5
|
||||||
games closest to 100 %.
|
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"
|
- **Playtime history**: one row per game and day (a day counts at most 24 h in the stats and the chart), for the
|
||||||
dashboard banner.
|
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
|
- **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
|
"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
|
3 candidates = a game without one end). When the user unlocks it, never chose the status and never answered
|
||||||
@@ -43,6 +109,27 @@ To Be Played is split into three repositories:
|
|||||||
weaker hint compares the playtime with IGDB's normal time to beat (at least 90 %), only for games IGDB does not
|
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
|
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.
|
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,
|
- **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
|
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
|
updates its playtime, but no achievements, time to beat, moods or speedrun data are fetched for it. The library
|
||||||
@@ -54,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
|
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
|
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
|
`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
|
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,
|
||||||
sums, most played, genres, moods and platforms still use every listed game.
|
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)
|
- **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
|
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
|
list and the number cannot drift. 40 games per page (at most page 250), a fixed order per key. A profile visitor
|
||||||
@@ -66,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
|
(old and new status, time, whether the user chose it), whatever code path made it. It starts with V14; older
|
||||||
changes are not known.
|
changes are not known.
|
||||||
- **Pile Points** (`modules/rewards`): users earn points for playing games off the pile, in an append-only ledger
|
- **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
|
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
|
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
|
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
|
||||||
a finish, at most 3 per ISO week. The settle takes a per-user advisory lock. V26 also pays a one-time starter grant
|
`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),
|
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
|
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
|
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
|
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
|
(`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 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,
|
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`
|
`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).
|
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
|
- **Editions and links**: editions, remasters and ports of one game stay separate library rows, but their keys are one
|
||||||
link their keys as one game for every user (see "Editions of one game"). IGDB suggests pairs, users can ask for
|
game (a set): IGDB's "same game" pairs are linked automatically, an AUDITOR can link or unlink keys for every user,
|
||||||
one, and one queue holds both. Every link, unlink and dismissal goes to `admin_audit`
|
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
|
(`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.
|
the actor's display name from Auth's account state push.
|
||||||
- **Wishlist**: synced from Steam with store price and discount.
|
- **Wishlist**: synced from Steam with store price and discount.
|
||||||
@@ -91,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
|
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,
|
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.
|
then after 30 days.
|
||||||
- **Time to beat**: story, extras and completionist hours from the IGDB API. Optional: without credentials the
|
- **Time to beat**: IGDB's `hastily`, `normally` and `completely` minutes (`game_time_to_beat`, V6). The Frontend labels
|
||||||
feature is off and the rest works.
|
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
|
- **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
|
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
|
groups the user shares (the ticket's `profileStats` claim, derived in Auth from the profile tiles); none by
|
||||||
default. A section the user
|
default. A section the user
|
||||||
@@ -112,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
|
(`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.
|
`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
|
`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
|
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.
|
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
|
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
|
||||||
@@ -142,44 +248,73 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
|
|
||||||
| Method | Path | Auth |
|
| 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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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?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 |
|
| `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/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 |
|
| `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 |
|
| `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/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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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}/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/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 |
|
| `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 |
|
| `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 |
|
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
|
||||||
| `GET` | `/api/library/picker?maxMinutes=&mood=&count=&exclude=` | JWT |
|
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
|
||||||
|
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
|
||||||
|
| `GET` | `/api/library/picker?maxMinutes=&mood=&exclude=&category=` (`{categories:[{source, games}]}`; each game has a `reason` object; `category` returns that row only) | JWT |
|
||||||
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
|
| `GET` | `/api/library/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/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
|
||||||
| `GET` | `/api/library/achievements/summary` | 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` | 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` | 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/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/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/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/stats/public/games?key=&arg=&page=` (the games behind a landing number, per game `libraries` and `minutes`; `400 BAD_ARG`) | public |
|
||||||
|
| `GET` | `/api/follows` (`{following: [{id, displayName, lastActivityAt}], followers: [{id, displayName, lastActivityAt}]}`, `id` is the follow row; a follower with a private profile has `displayName` null; `lastActivityAt` = the newest digest note the caller got about that person, null for followers and without a note) | login |
|
||||||
|
| `PUT`, `DELETE` | `/api/follows/by-name/{displayName}` (follow, unfollow; `204`; PUT: `404 PROFILE_NOT_FOUND` for an unknown, private, blocked or deleted name, `400 FOLLOW_SELF`, `400 FOLLOW_LIMIT`) | login |
|
||||||
|
| `DELETE` | `/api/follows/{id}` (unfollow, or remove a follower; only rows of the caller change; `204`) | login |
|
||||||
|
| `GET` | `/api/follows/players?q=` (`{results: [{displayName}]}`: public profiles whose name contains `q`, case-insensitive, not blocked or deleted, at most 20, names that start with `q` first, the caller's own name included; `q` under 2 characters answers `[]`; `429 TOO_MANY_SEARCHES` beyond 30 searches per user and minute, `app.follow.search-per-minute`) | login |
|
||||||
|
| `GET` | `/api/inbox` (`{results: [{id, kind, displayName, profileName, newGames, achievements, statusChanges, wishlistAdds, createdAt, read, details}], unread}`, newest 50, `kind` `DIGEST` or `VISIBILITY`) | login |
|
||||||
|
| `GET` | `/api/inbox/{id}/details` (`{results: [{appid, kind, gameName, itemName, fromStatus, toStatus}]}`, `kind` `NEW_GAME`, `ACHIEVEMENT`, `STATUS` or `WISHLIST`; `404` for a note that is not the caller's or not open any more) | login |
|
||||||
|
| `PUT` | `/api/inbox/read` (marks all read; `204`) | login |
|
||||||
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/deleted` (`{userIds}`; POST adds, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt}]}`, `displayName` and `roleChangedAt` optional, `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
|
| `POST`, `PUT` | `/internal/accounts/states` (`{accounts: [{userId, steamId, displayName, blocked, roleChangedAt, listed, publicProfile, profileSections}]}`, `displayName`, `roleChangedAt`, `listed` and `publicProfile` optional (`listed`: Auth lists the user by name, kept in `listed_user`, `null` keeps it; `publicProfile`: the profile is public, kept as a row in `public_user` with `profileSections`, `null` keeps row and sections, `false` ends the follows of the user with one `VISIBILITY` note each; `profileSections` `null` = all shown), `displayName` at most 100 characters, kept in `account_name` for the audit log, `roleChangedAt` kept in `account_role_change`; POST updates, PUT replaces the whole set; `204`) | service token |
|
||||||
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
| `POST` | `/internal/accounts/purge` (`{userIds}`), `/internal/accounts/prune` (`{knownUserIds}`, not empty) | service token |
|
||||||
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
| `GET` | `/api/version` (`{commit, builtAt}` of the running image, from the CI build; `dev` locally) | public |
|
||||||
|
|
||||||
@@ -189,9 +324,9 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
|
|||||||
- PostgreSQL 17, Flyway migrations
|
- PostgreSQL 17, Flyway migrations
|
||||||
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
|
- Resilience4j: retry and rate limiter for Steam, rate limiters for IGDB and speedrun.com
|
||||||
- JJWT with Ed25519 public key
|
- JJWT with Ed25519 public key
|
||||||
- Spring Boot Actuator + Micrometer (Prometheus)
|
- Spring Boot Actuator (`/actuator/health` only)
|
||||||
- Tests: JUnit 5, Testcontainers (PostgreSQL), ArchUnit
|
- 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
|
## Architecture
|
||||||
|
|
||||||
@@ -209,13 +344,17 @@ flowchart LR
|
|||||||
Stats["stats"]
|
Stats["stats"]
|
||||||
Version["version"]
|
Version["version"]
|
||||||
Account["account"]
|
Account["account"]
|
||||||
|
Follow["follow"]
|
||||||
|
Inbox["inbox"]
|
||||||
Rewards["rewards"]
|
Rewards["rewards"]
|
||||||
Steam["steam_adapter"]
|
Steam["steam_adapter"]
|
||||||
Igdb["igdb_adapter"]
|
Igdb["igdb_adapter"]
|
||||||
Speedrun["speedrun_adapter"]
|
Speedrun["speedrun_adapter"]
|
||||||
|
Psn["psn_adapter"]
|
||||||
LibService --> Steam
|
LibService --> Steam
|
||||||
LibService --> Igdb
|
LibService --> Igdb
|
||||||
LibService --> Speedrun
|
LibService --> Speedrun
|
||||||
|
LibService --> Psn
|
||||||
end
|
end
|
||||||
Browser -->|"tbp_access cookie"| Security
|
Browser -->|"tbp_access cookie"| Security
|
||||||
Rewards --> Db
|
Rewards --> Db
|
||||||
@@ -225,37 +364,50 @@ flowchart LR
|
|||||||
Igdb --> IgdbApi["IGDB API"]
|
Igdb --> IgdbApi["IGDB API"]
|
||||||
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
Igdb -->|"OAuth token"| Twitch["Twitch"]
|
||||||
Speedrun --> SpeedrunApi["speedrun.com API"]
|
Speedrun --> SpeedrunApi["speedrun.com API"]
|
||||||
LibRepo --> Db[("PostgreSQL tbp_core")]
|
Psn -->|"NPSSO sign-in, played titles, trophy sets and lists"| PsnApi["PlayStation API (unofficial)"]
|
||||||
Stats -->|"listed_user_game, counted_user_game, game_mood, user_game_status_change, deleted_user, blocked_user"| Db
|
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
|
||||||
|
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
|
||||||
|
Follow -->|"follow, follow_digest, inbox_note, inbox_note_detail, public_user, sync_change, user_game_status_change, user_game_achievement, user_psn_trophy, wishlist_game, account_name"| Db
|
||||||
|
Inbox -->|"inbox_note, inbox_note_detail, follow, public_user, user_game, account_name, blocked_user, deleted_user"| Db
|
||||||
Auth -->|"service token<br/>/internal/accounts"| Account
|
Auth -->|"service token<br/>/internal/accounts"| Account
|
||||||
Account -->|"per-user rows, deleted_user, blocked_user, account_name, user_seen_at"| Db
|
Account -->|"per-user rows, deleted_user, blocked_user, listed_user, public_user, follow, account_name, user_seen_at"| Db
|
||||||
```
|
```
|
||||||
|
|
||||||
| Module | Content |
|
| 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 |
|
| `stats` | Public numbers for the landing page |
|
||||||
| `version` | `GET /api/version`: commit and build time of the running image |
|
| `version` | `GET /api/version`: commit and build time of the running image |
|
||||||
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
|
||||||
|
| `inbox` | `inbox_note`, `inbox_note_detail`: the notes of a user, `GET /api/inbox`, `GET /api/inbox/{id}/details`, the 90-day purge |
|
||||||
|
| `account` | Driven by Auth: tombstones in `deleted_user`, purge and orphan prune of the per-user rows; blocks in `blocked_user`, listing in `listed_user` (users Auth shows by name in owner lists, #26), public profiles and their sections in `public_user` (#27), role changes in `account_role_change`, display names in `account_name`, Steam ID changes stop the nightly sync |
|
||||||
| `steam_adapter` | Steam Web API client, availability check of Steam CDN pictures |
|
| `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 |
|
| `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 |
|
| `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
|
**One shared schema.** All modules use one database `tbp_core`, and some read or write another module's tables in
|
||||||
SQL, which ArchUnit cannot see: `stats` reads `listed_user_game`, `counted_user_game`, `steam_game`, `game_mood`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
SQL, which ArchUnit cannot see: `stats` reads `listed_user`, `account_name`, `game_alias`, `listed_user_game`, `counted_user_game`, `user_game_entry_member`, `steam_game`, `game_mood`, `coming_game`, `user_game_status_change`, `deleted_user` and `blocked_user`; `account`
|
||||||
deletes the per-user rows of the library tables (`AccountCleanupRepository`; a new library table with `user_id` goes
|
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`
|
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
|
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
|
`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
|
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`
|
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:
|
(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
|
- Modules do not depend on each other. Only `library` may use `steam_adapter`, `igdb_adapter`,
|
||||||
`speedrun_adapter`.
|
`speedrun_adapter` and `psn_adapter`.
|
||||||
- No cycles between `modules`, `shared` and `configuration`.
|
- No cycles between `modules`, `shared` and `configuration`.
|
||||||
- `api` never uses `repo`, and `repo` never uses `api`.
|
- `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
|
- `domain` never uses `api`, `service`, `repo` or an adapter: the import stores IGDB hits as the own record
|
||||||
@@ -269,9 +421,11 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
|
|
||||||
### Sync flow
|
### Sync flow
|
||||||
|
|
||||||
1. `GET /api/library/games`, `/games-count` and `/picker` call `LibrarySyncService.syncIfStale`. It starts a sync
|
1. `GET /api/library/games`, `/dashboard-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.
|
when the last success is older than 20 hours (`app.library.sync-ttl-hours`) and the last try older than 60 minutes
|
||||||
`POST /api/library/sync` starts one at once, but at most every 60 seconds (else `429` with `Retry-After`).
|
(`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
|
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.
|
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
|
Afterwards it writes every user's pile size of the day to `backlog_snapshot`, also for users without Steam, and
|
||||||
@@ -282,33 +436,73 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
|
|||||||
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
|
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
|
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
|
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.
|
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
|
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.
|
games and manual games. Hidden games only get their playtime updated.
|
||||||
6. After the commit, `LibrarySyncedEvent` starts `AchievementSyncService` and `SpeedrunService` (backlog games only)
|
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
|
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.
|
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.
|
The frontend polls `GET /api/library/sync-status` until the run ends.
|
||||||
|
|
||||||
|
Followers (#27): when the achievement run ends, `AchievementSyncService` publishes `LibraryActivityEvent`, and
|
||||||
|
`FollowDigestService` writes one inbox note per follower from the new sync result (`ADDED`, `ACHIEVEMENTS`, `STATUS`
|
||||||
|
and `WISHLIST` rows of `sync_change`) and the hand changes since the cursor. Kinds that the profile hides (`public_user.sections`)
|
||||||
|
are left out, and the cursor moves anyway. A job every 10 minutes reports the hand changes alone once a user was quiet for 10
|
||||||
|
minutes; it never takes a sync result, which may still wait for its achievements.
|
||||||
|
|
||||||
|
`ALL_UNLOCKED`: the sync unlocked the last Steam achievement of a game that is not COMPLETED; Core never sets COMPLETED itself, the user confirms it in the sync details.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
sequenceDiagram
|
||||||
|
participant W as LibrarySyncWorker
|
||||||
|
participant P as PsnSyncStep + PsnTrophyStep
|
||||||
|
participant A as AchievementSyncService
|
||||||
|
participant F as FollowDigestService
|
||||||
|
participant D as Db
|
||||||
|
W->>P: run (titles, trophy summaries, changed lists)
|
||||||
|
P->>D: save title play (user_psn_title)
|
||||||
|
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
|
||||||
|
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
|
||||||
|
W-->>A: LibrarySyncedEvent
|
||||||
|
A->>D: sync_change (ACHIEVEMENTS, ALL_UNLOCKED)
|
||||||
|
A-->>F: LibraryActivityEvent
|
||||||
|
F->>D: lock follow_digest, count sync + hand changes
|
||||||
|
F->>D: inbox_note per follower (public, not blocked, not deleted)
|
||||||
|
F->>D: move cursor
|
||||||
|
```
|
||||||
|
|
||||||
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
|
At 01:30 `ImageRepairService` repairs the pictures reported through `POST /api/library/broken-images` (table
|
||||||
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
|
`broken_image`, one row per game and kind). Reports only count for Steam games the user owns.
|
||||||
|
|
||||||
### Background work
|
### 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
|
- 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
|
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
|
a warning, and the next view or sync asks again. Known limit: a dropped sync still answers `202`; the user
|
||||||
starts it again.
|
starts it again.
|
||||||
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and every speedrun
|
- The follow-up pool (2 threads, no limit on the queue) runs the achievement runs after a sync and the speedrun
|
||||||
refresh. They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take the page
|
refreshes (`@Async`). They hold a thread for minutes because of the Steam and speedrun.com pace, so they do not take
|
||||||
views' threads. A task is small (a user or a few appids), and none is dropped.
|
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,
|
Imports run in two lanes, `game-import-match` and `game-import-save`, 1 thread each, queue 1; `game_import` is their
|
||||||
import watchdog) run on the scheduler.
|
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
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
@@ -316,10 +510,12 @@ flowchart LR
|
|||||||
Sync["sync request"] --> Pool
|
Sync["sync request"] --> Pool
|
||||||
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
|
Synced["sync, after commit"] --> FollowUp["follow-up pool<br/>2 threads"]
|
||||||
View -->|"speedrun"| FollowUp
|
View -->|"speedrun"| FollowUp
|
||||||
|
FollowUp --> Drainer["speedrunExecutor<br/>1 thread"]
|
||||||
Pool --> Apis["Steam, IGDB, speedrun.com"]
|
Pool --> Apis["Steam, IGDB, speedrun.com"]
|
||||||
FollowUp --> Apis
|
FollowUp --> Apis
|
||||||
|
Drainer --> Apis
|
||||||
Import["POST /imports"] --> Lanes["GameImportWorker<br/>2 own threads"]
|
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
|
Core runs as one instance. The pools, the speedrun queue, the per-user achievement runs and the achievement locks
|
||||||
@@ -401,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
|
`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.
|
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.
|
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
|
- **Auto link** (V42): `GameEditionService.linkSameGames` links every pair of keys with the same `same_game_id`, after each
|
||||||
read; a row in any state keeps the pair out for good. An unlink dismisses the pairs of that key with the former
|
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
|
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`.
|
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
|
- Every list of the user's own games shows a set once, as its counted edition (`counted_user_game`, see "Stats"): the
|
||||||
filter `hideDuplicates=true` hides the lesser editions. Pile Points are unchanged: `STARTED` and `FINISHED` stay per row,
|
library and its total and badge, the dashboard, the recent games, the public profile lists, stats and their
|
||||||
and the `MONTH` bonus reads `backlog_snapshot`, which counts a set once.
|
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`
|
- 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
|
### Deleted accounts
|
||||||
|
|
||||||
@@ -420,12 +672,16 @@ gets no more syncs and leaves the landing stats and the pile snapshots at once.
|
|||||||
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
flagged set (`PUT`), then `purge` for users flagged 30 days ago (only tombstoned ones), then `prune` with all its
|
||||||
user ids, which removes the rows of users Auth does not know (refused for more than
|
user ids, which removes the rows of users Auth does not know (refused for more than
|
||||||
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
`app.account-cleanup.max-orphans`, default 5). `app.account-cleanup.dry-run` (default `true`) only counts the rows.
|
||||||
The purge also removes the user's `account_name` row and link requests (`game_link_request`).
|
The tombstone also ends the user's follows in both directions without a note and removes the user's `public_user` and
|
||||||
|
`follow_digest` rows; the purge removes the user's inbox notes and the notes about the user.
|
||||||
|
The purge also removes the user's `account_name` and `listed_user` rows, link requests (`game_link_request`), splits
|
||||||
|
(`user_game_split`) and set backups (`user_game_set_backup`).
|
||||||
|
The tombstone also deletes the user's stored PlayStation token (`user_psn_account`) at once.
|
||||||
|
|
||||||
### Blocked users and Steam ID changes
|
### Blocked users and Steam ID changes
|
||||||
|
|
||||||
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
Auth sends a user's state (`POST /internal/accounts/states`) after a Steam link, an unlink, a Steam ID another account
|
||||||
took by a verified link, a display name change, a block and an unblock; its nightly job sends every user (`PUT`). A blocked user is in
|
took by a verified link, a display name change, a block, an unblock and a change of the owner-list opt-in (`listed`, #26), of the public profile or of the profile tiles (`publicProfile`, `profileSections`, #27); its nightly job sends every user (`PUT`). A blocked user is in
|
||||||
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
|
`blocked_user`: no nightly sync, no new sync run, not in the landing stats. `JwtAuthenticationFilter` refuses a blocked
|
||||||
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
|
user's token and a token issued before the user's last role change (`account_role_change`, sent as `roleChangedAt`),
|
||||||
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
|
through the cache `loginStates` (30 s, evicted by every state call). An Auth that does not send `roleChangedAt` changes
|
||||||
@@ -445,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
|
`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
|
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`)
|
`STEAM`, and status, rating and notes stay. `PSN` rows (a PlayStation import, see below) live the same way. Only `MANUAL` and `PSN` rows can be edited (`PUT /api/library/games/{appid}/manual`)
|
||||||
or deleted (`DELETE /api/library/games/{appid}`). Time to beat uses the Steam appid for positive keys and the IGDB
|
or removed (`DELETE /api/library/games/{appid}`, soft: it sets `removed_at`, the row keeps status, rating, notes
|
||||||
|
and history) and restored (`POST /api/library/games/{appid}/restore`, see the diagram). Time to beat uses the Steam appid for positive keys and the IGDB
|
||||||
id for negative keys. Search and add check both keys of a game. A game added under minus its IGDB id can still get
|
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
|
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").
|
(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
|
### Imports
|
||||||
|
|
||||||
Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one
|
Many games at once (V22, `GameImportService`): a list, a CSV or several picks of the game search become one
|
||||||
@@ -487,10 +753,83 @@ stateDiagram-v2
|
|||||||
FAILED --> UNDONE: DELETE /games (the games added before it failed)
|
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;
|
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.
|
`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
|
## Run locally
|
||||||
|
|
||||||
### Local development without keys (profile `local`)
|
### Local development without keys (profile `local`)
|
||||||
@@ -505,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`
|
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
|
(`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`.
|
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.
|
games, achievements, time to beat, moods, a wishlist and one speedrun game from recorded answers.
|
||||||
|
|
||||||
What the `local` profile does:
|
What the `local` profile does:
|
||||||
@@ -524,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.
|
- 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.
|
- "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.
|
- 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
|
- 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.
|
Steam copy, so the link queue (auditor) lists the pair with the IGDB badge.
|
||||||
|
|
||||||
@@ -541,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
|
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
|
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:
|
Configuration comes from environment variables:
|
||||||
|
|
||||||
@@ -554,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 |
|
| `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 |
|
| `STEAM_API_KEY` | Steam Web API key |
|
||||||
| `IGDB_CLIENT_ID`, `IGDB_CLIENT_SECRET` | Twitch app for IGDB (optional) |
|
| `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
|
## Tests
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
|
|||||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
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.
|
* asks again.
|
||||||
*/
|
*/
|
||||||
@Configuration
|
@Configuration
|
||||||
@@ -43,4 +43,14 @@ public class AsyncConfig {
|
|||||||
executor.setThreadNamePrefix("sync-follow-up-");
|
executor.setThreadNamePrefix("sync-follow-up-");
|
||||||
return executor;
|
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/");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -65,7 +65,8 @@ public class InternalAccountController {
|
|||||||
private static List<AccountState> toStates(AccountStatesRequest request) {
|
private static List<AccountState> toStates(AccountStatesRequest request) {
|
||||||
return request.accounts().stream()
|
return request.accounts().stream()
|
||||||
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
|
.map(account -> new AccountState(account.userId(), account.steamId(), account.displayName(),
|
||||||
account.blocked(), account.roleChangedAt()))
|
account.blocked(), account.roleChangedAt(), account.listed(), account.publicProfile(),
|
||||||
|
account.profileSections()))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ public record AccountStatesRequest(@NotNull List<@Valid AccountStateDto> account
|
|||||||
/**
|
/**
|
||||||
* @param displayName null from an Auth that does not send it yet
|
* @param displayName null from an Auth that does not send it yet
|
||||||
* @param roleChangedAt null from an Auth that does not send it yet
|
* @param roleChangedAt null from an Auth that does not send it yet
|
||||||
|
* @param listed null from an Auth that does not send it yet
|
||||||
|
* @param publicProfile null from an Auth that does not send it yet
|
||||||
|
* @param profileSections the profile parts the user shows; null = all, or an Auth that does not send them. Unknown names
|
||||||
|
* are dropped by {@code AccountState}
|
||||||
*/
|
*/
|
||||||
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
|
public record AccountStateDto(@NotNull Long userId, Long steamId, @Size(max = 100) String displayName,
|
||||||
boolean blocked, Instant roleChangedAt) {
|
boolean blocked, Instant roleChangedAt, Boolean listed,
|
||||||
|
Boolean publicProfile, List<String> profileSections) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
package core_service.modules.account.domain;
|
package core_service.modules.account.domain;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What Core copies from Auth for one user.
|
* What Core copies from Auth for one user.
|
||||||
@@ -8,6 +10,17 @@ import java.time.Instant;
|
|||||||
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
* @param steamId null when the user has no Steam ID (unlinked, or taken by another account's verified link)
|
||||||
* @param displayName null when Auth did not send one; Core then keeps the stored name
|
* @param displayName null when Auth did not send one; Core then keeps the stored name
|
||||||
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
|
* @param roleChangedAt null from an Auth that does not send it; Core then keeps the stored value
|
||||||
|
* @param listed null from an Auth that does not send it; Core then keeps the stored listing
|
||||||
|
* @param publicProfile null from an Auth that does not send it; Core then keeps the stored row and its sections
|
||||||
|
* @param profileSections the profile parts the user shows; null = all shown
|
||||||
*/
|
*/
|
||||||
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt) {
|
public record AccountState(long userId, Long steamId, String displayName, boolean blocked, Instant roleChangedAt,
|
||||||
|
Boolean listed, Boolean publicProfile, List<String> profileSections) {
|
||||||
|
|
||||||
|
// One odd entry must not fail the push of every user, so unknown names are dropped, not refused.
|
||||||
|
private static final Set<String> SECTIONS = Set.of("SUMMARY", "STATUS", "PLAYING", "FINISHED", "STATS");
|
||||||
|
|
||||||
|
public AccountState {
|
||||||
|
profileSections = profileSections == null ? null : profileSections.stream().filter(section -> section != null && SECTIONS.contains(section)).toList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -19,15 +19,15 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AccountCleanupRepository {
|
public class AccountCleanupRepository {
|
||||||
|
|
||||||
// Children before user_game (user_game_status_change would also go by its cascade), the tombstones last.
|
// Children before user_game (several would also go by its cascade), the tombstones last.
|
||||||
static final List<String> TABLES = List.of("user_game_status_change", "user_game_achievement",
|
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",
|
"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", "deleted_user");
|
"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.
|
// 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",
|
private static final List<String> ORPHAN_SOURCES = List.of("user_game", "user_steam_sync", "wishlist_game",
|
||||||
"game_import", "blocked_user", "account_role_change", "deleted_user", "account_name");
|
"game_import", "user_psn_account", "blocked_user", "listed_user", "account_role_change", "deleted_user", "account_name", "public_user", "follow", "inbox_note");
|
||||||
|
|
||||||
private final NamedParameterJdbcTemplate jdbc;
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
@@ -36,7 +36,8 @@ public class AccountCleanupRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Also takes the users off the nightly sync list.
|
* Also takes the users off the nightly sync list, ends their follows, both ways, without a note, and drops their
|
||||||
|
* stored PlayStation token at once.
|
||||||
*/
|
*/
|
||||||
public void addDeleted(Collection<Long> userIds) {
|
public void addDeleted(Collection<Long> userIds) {
|
||||||
if (userIds.isEmpty()) {
|
if (userIds.isEmpty()) {
|
||||||
@@ -45,6 +46,10 @@ public class AccountCleanupRepository {
|
|||||||
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing",
|
jdbc.batchUpdate("insert into deleted_user (user_id) values (:id) on conflict do nothing",
|
||||||
userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new));
|
userIds.stream().map(id -> new MapSqlParameterSource("id", id)).toArray(MapSqlParameterSource[]::new));
|
||||||
jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds));
|
jdbc.update("update user_steam_sync set user_seen_at = null where user_id in (:ids)", ids(userIds));
|
||||||
|
jdbc.update("delete from follow where user_id in (:ids) or followed_user_id in (:ids)", ids(userIds));
|
||||||
|
jdbc.update("delete from follow_digest where user_id in (:ids)", ids(userIds));
|
||||||
|
jdbc.update("delete from public_user where user_id in (:ids)", ids(userIds));
|
||||||
|
jdbc.update("delete from user_psn_account where user_id in (:ids)", ids(userIds));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +93,7 @@ public class AccountCleanupRepository {
|
|||||||
for (String table : TABLES) {
|
for (String table : TABLES) {
|
||||||
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
|
counts.put(table, jdbc.update("delete from " + table + " where user_id in (:ids)", ids(userIds)));
|
||||||
}
|
}
|
||||||
|
jdbc.update("delete from inbox_note where subject_user_id in (:ids)", ids(userIds));
|
||||||
// The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
|
// The merge copy keeps the users' link requests as JSON, outside game_merge_undo_user.
|
||||||
jdbc.update("""
|
jdbc.update("""
|
||||||
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
|
update game_merge_undo set rows = jsonb_set(rows, '{game_link_request}',
|
||||||
|
|||||||
@@ -12,8 +12,9 @@ import java.util.Collection;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Blocks in blocked_user, role changes in account_role_change, the display name in account_name, and what a Steam ID
|
* Blocks in blocked_user, listing in listed_user, public profiles in public_user (and the follows that end with them),
|
||||||
* change means for user_steam_sync.
|
* role changes in account_role_change, the display name in account_name, and what a Steam ID change means for
|
||||||
|
* user_steam_sync.
|
||||||
* Plain SQL, because the library module owns the entities.
|
* Plain SQL, because the library module owns the entities.
|
||||||
*/
|
*/
|
||||||
@Repository
|
@Repository
|
||||||
@@ -41,6 +42,12 @@ public class AccountStateRepository {
|
|||||||
} else {
|
} else {
|
||||||
jdbc.update("delete from blocked_user where user_id = :userId", params);
|
jdbc.update("delete from blocked_user where user_id = :userId", params);
|
||||||
}
|
}
|
||||||
|
if (Boolean.TRUE.equals(state.listed())) {
|
||||||
|
jdbc.update("insert into listed_user (user_id) values (:userId) on conflict do nothing", params);
|
||||||
|
} else if (Boolean.FALSE.equals(state.listed())) {
|
||||||
|
jdbc.update("delete from listed_user where user_id = :userId", params);
|
||||||
|
}
|
||||||
|
applyPublicProfile(state, params);
|
||||||
if (state.blocked() || state.steamId() == null) {
|
if (state.blocked() || state.steamId() == null) {
|
||||||
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
|
jdbc.update("update user_steam_sync set user_seen_at = null where user_id = :userId and user_seen_at is not null",
|
||||||
params);
|
params);
|
||||||
@@ -53,6 +60,26 @@ public class AccountStateRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A blocked user ends follows in both directions without a note; a user who goes private tells each follower.
|
||||||
|
private void applyPublicProfile(AccountState state, MapSqlParameterSource params) {
|
||||||
|
if (state.blocked()) {
|
||||||
|
jdbc.update("delete from follow where user_id = :userId or followed_user_id = :userId", params);
|
||||||
|
jdbc.update("delete from public_user where user_id = :userId", params);
|
||||||
|
} else if (Boolean.TRUE.equals(state.publicProfile())) {
|
||||||
|
String[] sections = state.profileSections() == null ? null : state.profileSections().toArray(String[]::new);
|
||||||
|
jdbc.update("insert into public_user (user_id, sections) values (:userId, cast(:sections as varchar[])) "
|
||||||
|
+ "on conflict (user_id) do update set sections = excluded.sections",
|
||||||
|
new MapSqlParameterSource(params.getValues()).addValue("sections", sections));
|
||||||
|
} else if (Boolean.FALSE.equals(state.publicProfile())) {
|
||||||
|
jdbc.update("delete from public_user where user_id = :userId", params);
|
||||||
|
jdbc.update("""
|
||||||
|
with ended as (delete from follow where followed_user_id = :userId returning user_id)
|
||||||
|
insert into inbox_note (user_id, kind, subject_user_id, subject_name)
|
||||||
|
select e.user_id, 'VISIBILITY', :userId, (select display_name from account_name where user_id = :userId)
|
||||||
|
from ended e""", params);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public LoginState findLoginState(long userId) {
|
public LoginState findLoginState(long userId) {
|
||||||
return jdbc.queryForObject("""
|
return jdbc.queryForObject("""
|
||||||
select exists(select 1 from blocked_user where user_id = :userId) as blocked,
|
select exists(select 1 from blocked_user where user_id = :userId) as blocked,
|
||||||
@@ -65,15 +92,29 @@ public class AccountStateRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* After this, exactly the blocked users of the list are blocked.
|
* After this, exactly the blocked users of the list are blocked, and the listed and public ones listed and public
|
||||||
|
* unless a state has no such flag.
|
||||||
*/
|
*/
|
||||||
public void replaceAll(Collection<AccountState> states) {
|
public void replaceAll(Collection<AccountState> states) {
|
||||||
states.forEach(this::apply);
|
states.forEach(this::apply);
|
||||||
List<Long> blocked = states.stream().filter(AccountState::blocked).map(AccountState::userId).toList();
|
keepOnly("blocked_user", states.stream().filter(AccountState::blocked).map(AccountState::userId).toList());
|
||||||
if (blocked.isEmpty()) {
|
if (states.stream().noneMatch(state -> state.listed() == null)) {
|
||||||
jdbc.update("delete from blocked_user", new MapSqlParameterSource());
|
keepOnly("listed_user", states.stream().filter(AccountState::listed).map(AccountState::userId).toList());
|
||||||
|
}
|
||||||
|
if (states.stream().noneMatch(state -> state.publicProfile() == null)) {
|
||||||
|
keepOnly("public_user", states.stream().filter(state -> state.publicProfile() && !state.blocked())
|
||||||
|
.map(AccountState::userId).toList());
|
||||||
|
// apply() already told the followers of a user who went private.
|
||||||
|
jdbc.update("delete from follow f where not exists (select 1 from public_user p where p.user_id = f.followed_user_id)",
|
||||||
|
new MapSqlParameterSource());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void keepOnly(String table, List<Long> userIds) {
|
||||||
|
if (userIds.isEmpty()) {
|
||||||
|
jdbc.update("delete from " + table, new MapSqlParameterSource());
|
||||||
} else {
|
} else {
|
||||||
jdbc.update("delete from blocked_user where user_id not in (:ids)", new MapSqlParameterSource("ids", blocked));
|
jdbc.update("delete from " + table + " where user_id not in (:ids)", new MapSqlParameterSource("ids", userIds));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package core_service.modules.follow.api;
|
||||||
|
|
||||||
|
import core_service.modules.follow.api.dto.FollowsDto;
|
||||||
|
import core_service.modules.follow.api.dto.PlayersDto;
|
||||||
|
import core_service.modules.follow.domain.FollowEntry;
|
||||||
|
import core_service.modules.follow.domain.FollowResult;
|
||||||
|
import core_service.modules.follow.domain.Follows;
|
||||||
|
import core_service.modules.follow.domain.PlayerSearch;
|
||||||
|
import core_service.modules.follow.service.FollowService;
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
|
import core_service.shared.security.UserPrincipal;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/follows")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FollowController {
|
||||||
|
|
||||||
|
private final FollowService followService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<FollowsDto> follows(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
Follows follows = followService.follows(principal.getIdAsLong());
|
||||||
|
return ResponseEntity.ok(new FollowsDto(toDto(follows.following()), toDto(follows.followers())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The caller's own name is a normal hit.
|
||||||
|
*/
|
||||||
|
@GetMapping("/players")
|
||||||
|
public ResponseEntity<?> players(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@RequestParam("q") @Size(max = 100) String q) {
|
||||||
|
PlayerSearch search = followService.searchPlayers(principal.getIdAsLong(), q);
|
||||||
|
if (search.limited()) {
|
||||||
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.body(new ErrorResponse("TOO_MANY_SEARCHES", "Too many searches"));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(new PlayersDto(search.names().stream().map(PlayersDto.Player::new).toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/by-name/{displayName}")
|
||||||
|
public ResponseEntity<?> follow(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("displayName") @Size(max = 100) String displayName) {
|
||||||
|
FollowResult result = followService.follow(principal.getIdAsLong(), displayName);
|
||||||
|
return switch (result) {
|
||||||
|
case FOLLOWED -> ResponseEntity.noContent().build();
|
||||||
|
case NOT_FOUND -> ResponseEntity.status(404)
|
||||||
|
.body(new ErrorResponse("PROFILE_NOT_FOUND", "No public profile with this name"));
|
||||||
|
case SELF -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_SELF", "You cannot follow yourself"));
|
||||||
|
case LIMIT -> ResponseEntity.badRequest().body(new ErrorResponse("FOLLOW_LIMIT", "You follow too many people"));
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/by-name/{displayName}")
|
||||||
|
public ResponseEntity<Void> unfollow(@AuthenticationPrincipal UserPrincipal principal,
|
||||||
|
@PathVariable("displayName") @Size(max = 100) String displayName) {
|
||||||
|
followService.unfollow(principal.getIdAsLong(), displayName);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends a follow of the caller, or removes a follower of the caller; a row of someone else stays.
|
||||||
|
*/
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<Void> remove(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
|
||||||
|
followService.remove(principal.getIdAsLong(), id);
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<FollowsDto.Person> toDto(List<FollowEntry> entries) {
|
||||||
|
return entries.stream().map(entry -> new FollowsDto.Person(entry.id(), entry.displayName(), entry.lastActivityAt())).toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package core_service.modules.follow.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Who the caller follows and who follows the caller. Names only: a person has no user id here.
|
||||||
|
*/
|
||||||
|
public record FollowsDto(List<Person> following, List<Person> followers) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id the follow row, to end it; not a user id
|
||||||
|
* @param displayName null for a follower with a private profile
|
||||||
|
* @param lastActivityAt following only: when the newest feed message about this person arrived; null without one
|
||||||
|
*/
|
||||||
|
public record Person(long id, String displayName, Instant lastActivityAt) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
package core_service.modules.follow.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public profiles found by name: names that start with the query first, then by name.
|
||||||
|
*/
|
||||||
|
public record PlayersDto(List<Player> results) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param displayName the key of the profile URL
|
||||||
|
*/
|
||||||
|
public record Player(String displayName) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* How far the followers of a user were told.
|
||||||
|
*
|
||||||
|
* @param syncStartedAt the last reported sync result, null when there was none
|
||||||
|
* @param wishlistChangeId the last reported WISHLIST row of sync_change
|
||||||
|
*/
|
||||||
|
public record Cursor(Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a followed user did in one batch. The section names are those of the profile ticket; this module must not
|
||||||
|
* import the library's enum.
|
||||||
|
*/
|
||||||
|
public record Digest(int newGames, int achievements, int statusChanges, int wishlistAdds) {
|
||||||
|
|
||||||
|
static final String SUMMARY = "SUMMARY";
|
||||||
|
static final List<String> STATUS_SECTIONS = List.of("STATUS", "PLAYING", "FINISHED");
|
||||||
|
|
||||||
|
public boolean isEmpty() {
|
||||||
|
return newGames == 0 && achievements == 0 && statusChanges == 0 && wishlistAdds == 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Digest plus(Digest other) {
|
||||||
|
return new Digest(newGames + other.newGames, achievements + other.achievements,
|
||||||
|
statusChanges + other.statusChanges, wishlistAdds + other.wishlistAdds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Leaves out what the profile hides. The wishlist is never on the profile, so it has no section.
|
||||||
|
*
|
||||||
|
* @param sections the profile parts the user shows, null = all
|
||||||
|
*/
|
||||||
|
public Digest visibleOn(Collection<String> sections) {
|
||||||
|
if (sections == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
boolean summary = sections.contains(SUMMARY);
|
||||||
|
boolean status = STATUS_SECTIONS.stream().anyMatch(sections::contains);
|
||||||
|
return new Digest(summary ? newGames : 0, summary ? achievements : 0, status ? statusChanges : 0, wishlistAdds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One line behind a digest count. Names are snapshots. The kind is a plain string: the inbox module has its own enum.
|
||||||
|
*/
|
||||||
|
public record DigestItem(String kind, long appid, String gameName, String itemName, String fromStatus, String toStatus) {
|
||||||
|
|
||||||
|
public static final String NEW_GAME = "NEW_GAME";
|
||||||
|
public static final String ACHIEVEMENT = "ACHIEVEMENT";
|
||||||
|
public static final String STATUS = "STATUS";
|
||||||
|
public static final String WISHLIST = "WISHLIST";
|
||||||
|
|
||||||
|
public static DigestItem newGame(long appid, String gameName) {
|
||||||
|
return new DigestItem(NEW_GAME, appid, gameName, null, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem achievement(long appid, String gameName, String itemName) {
|
||||||
|
return new DigestItem(ACHIEVEMENT, appid, gameName, itemName, null, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem status(long appid, String gameName, String from, String to) {
|
||||||
|
return new DigestItem(STATUS, appid, gameName, null, from, to);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DigestItem wishlist(long appid, String gameName) {
|
||||||
|
return new DigestItem(WISHLIST, appid, gameName, null, null, null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind a {@link Digest}, in the order they are shown.
|
||||||
|
*/
|
||||||
|
public record DigestItems(List<DigestItem> items) {
|
||||||
|
|
||||||
|
public DigestItems plus(DigestItems other) {
|
||||||
|
List<DigestItem> all = new ArrayList<>(items);
|
||||||
|
all.addAll(other.items);
|
||||||
|
return new DigestItems(all);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same rule as {@link Digest#visibleOn}.
|
||||||
|
*
|
||||||
|
* @param sections the profile parts the user shows, null = all
|
||||||
|
*/
|
||||||
|
public DigestItems visibleOn(Collection<String> sections) {
|
||||||
|
if (sections == null) {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
boolean summary = sections.contains(Digest.SUMMARY);
|
||||||
|
boolean status = Digest.STATUS_SECTIONS.stream().anyMatch(sections::contains);
|
||||||
|
return new DigestItems(items.stream().filter(item -> switch (item.kind()) {
|
||||||
|
case DigestItem.NEW_GAME, DigestItem.ACHIEVEMENT -> summary;
|
||||||
|
case DigestItem.STATUS -> status;
|
||||||
|
default -> true;
|
||||||
|
}).toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One STATUS line per game: the first old status to the last new one, dropped when they are equal. The line keeps
|
||||||
|
* the place of the first move of its game.
|
||||||
|
*/
|
||||||
|
public DigestItems collapseStatus() {
|
||||||
|
Map<Long, String> lastTo = new HashMap<>();
|
||||||
|
for (DigestItem item : items) {
|
||||||
|
if (DigestItem.STATUS.equals(item.kind())) {
|
||||||
|
lastTo.put(item.appid(), item.toStatus());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
List<DigestItem> result = new ArrayList<>();
|
||||||
|
Set<Long> seen = new HashSet<>();
|
||||||
|
for (DigestItem item : items) {
|
||||||
|
if (!DigestItem.STATUS.equals(item.kind())) {
|
||||||
|
result.add(item);
|
||||||
|
} else if (seen.add(item.appid())) {
|
||||||
|
String to = lastTo.get(item.appid());
|
||||||
|
if (!Objects.equals(item.fromStatus(), to)) {
|
||||||
|
result.add(DigestItem.status(item.appid(), item.gameName(), item.fromStatus(), to));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return new DigestItems(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param id the follow row, not a user id
|
||||||
|
* @param displayName null for a follower with a private profile
|
||||||
|
* @param lastActivityAt the newest digest note the caller got about this user; null for followers and without a note
|
||||||
|
*/
|
||||||
|
public record FollowEntry(long id, String displayName, Instant lastActivityAt) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
public enum FollowResult {
|
||||||
|
FOLLOWED,
|
||||||
|
NOT_FOUND,
|
||||||
|
SELF,
|
||||||
|
LIMIT
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record Follows(List<FollowEntry> following, List<FollowEntry> followers) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package core_service.modules.follow.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The answer of a player search: the names found, or "limited" when the caller searched too often.
|
||||||
|
*/
|
||||||
|
public record PlayerSearch(List<String> names, boolean limited) {
|
||||||
|
|
||||||
|
public static PlayerSearch tooMany() {
|
||||||
|
return new PlayerSearch(List.of(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static PlayerSearch of(List<String> names) {
|
||||||
|
return new PlayerSearch(names, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
package core_service.modules.follow.repo;
|
||||||
|
|
||||||
|
import core_service.modules.follow.domain.Cursor;
|
||||||
|
import core_service.modules.follow.domain.Digest;
|
||||||
|
import core_service.modules.follow.domain.DigestItem;
|
||||||
|
import core_service.modules.follow.domain.DigestItems;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.jdbc.core.namedparam.SqlParameterSource;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Array;
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The cursor per followed user, what happened since it, and the notes. Plain SQL: sync_change, sync_result,
|
||||||
|
* user_game and the trigger log belong to the library module, account tables to the account module.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FollowDigestRepository {
|
||||||
|
|
||||||
|
static final int MAX_ACHIEVEMENTS_PER_GAME = 20;
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks the row until the end of the transaction, so two digests of one user never report a batch twice.
|
||||||
|
*/
|
||||||
|
public Optional<Cursor> lockCursor(long userId) {
|
||||||
|
return jdbc.query("select sync_started_at, status_change_id, wishlist_change_id from follow_digest where user_id = :userId for update",
|
||||||
|
user(userId), (rs, i) -> {
|
||||||
|
Timestamp startedAt = rs.getTimestamp("sync_started_at");
|
||||||
|
return new Cursor(startedAt == null ? null : startedAt.toInstant(), rs.getLong("status_change_id"), rs.getLong("wishlist_change_id"));
|
||||||
|
}).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Optional<Instant> findSyncStartedAt(long userId) {
|
||||||
|
return jdbc.query("select sync_started_at from sync_result where user_id = :userId", user(userId),
|
||||||
|
(rs, i) -> rs.getTimestamp("sync_started_at").toInstant()).stream().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hidden and removed games never count. Wishlist adds are counted apart, see {@link #countWishlist}.
|
||||||
|
*/
|
||||||
|
public Digest countSync(long userId) {
|
||||||
|
return jdbc.queryForObject("""
|
||||||
|
select
|
||||||
|
(select count(distinct c.appid) from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null) as new_games,
|
||||||
|
(select coalesce(sum(c.amount), 0) from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null) as achievements,
|
||||||
|
(select count(*) from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'STATUS' and ug.hidden_at is null and ug.removed_at is null) as status_changes""",
|
||||||
|
user(userId),
|
||||||
|
(rs, i) -> new Digest(rs.getInt("new_games"), rs.getInt("achievements"), rs.getInt("status_changes"), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxWishlistChangeId(long userId) {
|
||||||
|
Long max = jdbc.queryForObject(
|
||||||
|
"select coalesce(max(id), 0) from sync_change where user_id = :userId and kind = 'WISHLIST'",
|
||||||
|
user(userId), Long.class);
|
||||||
|
return max != null ? max : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* By row id, not by sync result: an add can join a result that was reported already. Wishlist games are no
|
||||||
|
* library games, so a hidden game does not matter.
|
||||||
|
*/
|
||||||
|
public int countWishlist(long userId, long afterId, long upToId) {
|
||||||
|
Integer count = jdbc.queryForObject("select count(*) from sync_change where user_id = :userId "
|
||||||
|
+ "and kind = 'WISHLIST' and id > :afterId and id <= :upToId",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId), Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public long maxStatusChangeId(long userId) {
|
||||||
|
Long max = jdbc.queryForObject("select coalesce(max(id), 0) from user_game_status_change where user_id = :userId",
|
||||||
|
user(userId), Long.class);
|
||||||
|
return max != null ? max : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The newest hand change after {@code afterId}, or {@code afterId} when there is none. A hand-only digest stops
|
||||||
|
* there, so the status rows of a sync stay for the digest of that sync.
|
||||||
|
*/
|
||||||
|
public long maxHandStatusChangeId(long userId, long afterId) {
|
||||||
|
Long max = jdbc.queryForObject("select max(id) from user_game_status_change "
|
||||||
|
+ "where user_id = :userId and by_user and id > :afterId",
|
||||||
|
user(userId).addValue("afterId", afterId), Long.class);
|
||||||
|
return max != null ? max : afterId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The changes the user made by hand: no old status = a game added by hand, else a status change.
|
||||||
|
*/
|
||||||
|
public Digest countHand(long userId, long afterId, long upToId) {
|
||||||
|
return jdbc.queryForObject("""
|
||||||
|
select count(*) filter (where c.from_status is null) as new_games,
|
||||||
|
count(*) filter (where c.from_status is not null) as status_changes
|
||||||
|
from user_game_status_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.by_user and c.id > :afterId and c.id <= :upToId
|
||||||
|
and ug.hidden_at is null and ug.removed_at is null""",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
|
||||||
|
(rs, i) -> new Digest(rs.getInt("new_games"), 0, rs.getInt("status_changes"), 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countSync}, same joins and filters. Achievement names are not stored by the sync, only
|
||||||
|
* how many: the newest unlocks of the game (Steam and PlayStation together) stand for the new ones, at most
|
||||||
|
* {@link #MAX_ACHIEVEMENTS_PER_GAME} per game.
|
||||||
|
*/
|
||||||
|
public DigestItems findSyncItems(long userId) {
|
||||||
|
List<DigestItem> items = new ArrayList<>(findSyncNewGames(userId));
|
||||||
|
items.addAll(findSyncAchievements(userId));
|
||||||
|
return new DigestItems(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DigestItem> findSyncNewGames(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select distinct c.appid, sg.name from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ADDED' and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
order by sg.name, c.appid""", user(userId), (rs, i) -> DigestItem.newGame(rs.getLong("appid"), rs.getString("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<DigestItem> findSyncAchievements(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
with gained as (
|
||||||
|
select c.appid, sum(c.amount) as amount from sync_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'ACHIEVEMENTS' and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
group by c.appid),
|
||||||
|
unlocks as (
|
||||||
|
select a.appid, coalesce(s.display_name, a.api_name) as name, a.unlocked_at as at
|
||||||
|
from user_game_achievement a
|
||||||
|
left join game_achievement_schema s on s.appid = a.appid and s.api_name = a.api_name
|
||||||
|
where a.user_id = :userId and a.unlocked
|
||||||
|
union all
|
||||||
|
select st.appid, coalesce(t.name, 'Trophy ' || t.trophy_id), ut.earned_at
|
||||||
|
from user_psn_trophy ut
|
||||||
|
join user_psn_trophy_set st on st.user_id = ut.user_id and st.np_communication_id = ut.np_communication_id
|
||||||
|
join psn_trophy t on t.np_communication_id = ut.np_communication_id and t.trophy_id = ut.trophy_id
|
||||||
|
where ut.user_id = :userId),
|
||||||
|
ranked as (
|
||||||
|
select u.appid, u.name, row_number() over (partition by u.appid order by u.at desc nulls last, u.name) as rn
|
||||||
|
from unlocks u join gained g on g.appid = u.appid)
|
||||||
|
select r.appid, sg.name as game_name, r.name from ranked r
|
||||||
|
join gained g on g.appid = r.appid
|
||||||
|
left join steam_game sg on sg.appid = r.appid
|
||||||
|
where r.rn <= least(g.amount, :cap)
|
||||||
|
order by sg.name, r.appid, r.rn""", user(userId).addValue("cap", MAX_ACHIEVEMENTS_PER_GAME),
|
||||||
|
(rs, i) -> DigestItem.achievement(rs.getLong("appid"), rs.getString("game_name"), rs.getString("name")));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countHand}, same filters. With {@code withSyncMoves} the status moves of the sync are
|
||||||
|
* read from the same log, so a game's moves by hand and by sync stay in time order for the collapse.
|
||||||
|
*/
|
||||||
|
public DigestItems findHandItems(long userId, long afterId, long upToId, boolean withSyncMoves) {
|
||||||
|
return new DigestItems(jdbc.query("""
|
||||||
|
select c.appid, sg.name, c.from_status, c.to_status
|
||||||
|
from user_game_status_change c
|
||||||
|
join user_game ug on ug.user_id = c.user_id and ug.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.id > :afterId and c.id <= :upToId
|
||||||
|
and (c.by_user or (:withSyncMoves and c.from_status is not null))
|
||||||
|
and ug.hidden_at is null and ug.removed_at is null
|
||||||
|
order by c.id""",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId).addValue("withSyncMoves", withSyncMoves),
|
||||||
|
(rs, i) -> rs.getString("from_status") == null
|
||||||
|
? DigestItem.newGame(rs.getLong("appid"), rs.getString("name"))
|
||||||
|
: DigestItem.status(rs.getLong("appid"), rs.getString("name"), rs.getString("from_status"),
|
||||||
|
rs.getString("to_status"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The lines behind {@link #countWishlist}, same id range.
|
||||||
|
*/
|
||||||
|
public DigestItems findWishlistItems(long userId, long afterId, long upToId) {
|
||||||
|
return new DigestItems(jdbc.query("""
|
||||||
|
select c.appid, coalesce(w.name, sg.name, 'App ' || c.appid) as name from sync_change c
|
||||||
|
left join wishlist_game w on w.user_id = c.user_id and w.appid = c.appid
|
||||||
|
left join steam_game sg on sg.appid = c.appid
|
||||||
|
where c.user_id = :userId and c.kind = 'WISHLIST' and c.id > :afterId and c.id <= :upToId
|
||||||
|
order by c.id""",
|
||||||
|
user(userId).addValue("afterId", afterId).addValue("upToId", upToId),
|
||||||
|
(rs, i) -> DigestItem.wishlist(rs.getLong("appid"), rs.getString("name"))));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return null when there is no row or the user did not send sections: nothing is filtered
|
||||||
|
*/
|
||||||
|
public List<String> findPublicSections(long userId) {
|
||||||
|
return jdbc.query("select sections from public_user where user_id = :userId", user(userId), rs -> {
|
||||||
|
if (!rs.next()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
Array sections = rs.getArray("sections");
|
||||||
|
return sections == null ? null : Arrays.asList((String[]) sections.getArray());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One note per follower, only while the followed user is public, not blocked and not deleted and the follower is
|
||||||
|
* not deleted. A name is stored as it is now.
|
||||||
|
*
|
||||||
|
* @return the ids of the notes written
|
||||||
|
*/
|
||||||
|
public List<Long> insertDigestNotes(long userId, Digest digest) {
|
||||||
|
return jdbc.query("""
|
||||||
|
insert into inbox_note (user_id, kind, subject_user_id, subject_name, new_games, achievements,
|
||||||
|
status_changes, wishlist_adds)
|
||||||
|
select f.user_id, 'DIGEST', f.followed_user_id,
|
||||||
|
(select display_name from account_name where user_id = f.followed_user_id),
|
||||||
|
:newGames, :achievements, :statusChanges, :wishlistAdds
|
||||||
|
from follow f
|
||||||
|
where f.followed_user_id = :userId
|
||||||
|
and exists (select 1 from public_user p where p.user_id = f.followed_user_id)
|
||||||
|
and not exists (select 1 from blocked_user b where b.user_id = f.followed_user_id)
|
||||||
|
and not exists (select 1 from deleted_user d where d.user_id in (f.followed_user_id, f.user_id))
|
||||||
|
returning id""",
|
||||||
|
user(userId).addValue("newGames", digest.newGames()).addValue("achievements", digest.achievements())
|
||||||
|
.addValue("statusChanges", digest.statusChanges()).addValue("wishlistAdds", digest.wishlistAdds()),
|
||||||
|
(rs, i) -> rs.getLong("id"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One copy of the lines per note: the note owns them and they go with it.
|
||||||
|
*/
|
||||||
|
public void insertDigestItems(List<Long> noteIds, DigestItems items) {
|
||||||
|
if (noteIds.isEmpty() || items.items().isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
List<SqlParameterSource> rows = new ArrayList<>();
|
||||||
|
for (long noteId : noteIds) {
|
||||||
|
for (DigestItem item : items.items()) {
|
||||||
|
rows.add(new MapSqlParameterSource("noteId", noteId).addValue("kind", item.kind())
|
||||||
|
.addValue("appid", item.appid()).addValue("gameName", item.gameName())
|
||||||
|
.addValue("itemName", item.itemName()).addValue("fromStatus", item.fromStatus())
|
||||||
|
.addValue("toStatus", item.toStatus()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
jdbc.batchUpdate("insert into inbox_note_detail (note_id, kind, appid, game_name, item_name, from_status, to_status) "
|
||||||
|
+ "values (:noteId, :kind, :appid, :gameName, :itemName, :fromStatus, :toStatus)",
|
||||||
|
rows.toArray(new SqlParameterSource[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void moveCursor(long userId, Instant syncStartedAt, long statusChangeId, long wishlistChangeId) {
|
||||||
|
jdbc.update("update follow_digest set sync_started_at = :syncStartedAt, status_change_id = :statusChangeId, "
|
||||||
|
+ "wishlist_change_id = :wishlistChangeId "
|
||||||
|
+ "where user_id = :userId",
|
||||||
|
user(userId).addValue("syncStartedAt", syncStartedAt == null ? null : Timestamp.from(syncStartedAt))
|
||||||
|
.addValue("statusChangeId", statusChangeId).addValue("wishlistChangeId", wishlistChangeId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Followed users with a pending hand change whose newest pending change is older than {@code quietBefore}.
|
||||||
|
*/
|
||||||
|
public List<Long> findQuietHandChanges(Instant quietBefore) {
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
select d.user_id from follow_digest d
|
||||||
|
where exists (select 1 from follow f where f.followed_user_id = d.user_id)
|
||||||
|
and exists (select 1 from user_game_status_change c
|
||||||
|
where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id)
|
||||||
|
and not exists (select 1 from user_game_status_change c
|
||||||
|
where c.user_id = d.user_id and c.by_user and c.id > d.status_change_id
|
||||||
|
and c.changed_at > :quietBefore)""",
|
||||||
|
new MapSqlParameterSource("quietBefore", Timestamp.from(quietBefore)), Long.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MapSqlParameterSource user(long userId) {
|
||||||
|
return new MapSqlParameterSource("userId", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package core_service.modules.follow.repo;
|
||||||
|
|
||||||
|
import core_service.modules.follow.domain.FollowEntry;
|
||||||
|
import core_service.shared.sql.LikePattern;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.RowMapper;
|
||||||
|
import org.springframework.jdbc.core.namedparam.MapSqlParameterSource;
|
||||||
|
import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Follow rows. Plain SQL: the tables of other modules (account_name, public_user, blocked_user, deleted_user) are
|
||||||
|
* read, not mapped.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FollowRepository {
|
||||||
|
|
||||||
|
static final int LIST_LIMIT = 500;
|
||||||
|
public static final int SEARCH_LIMIT = 20;
|
||||||
|
|
||||||
|
private static final RowMapper<FollowEntry> ENTRY = (rs, i) -> {
|
||||||
|
Timestamp lastActivityAt = rs.getTimestamp("last_activity_at");
|
||||||
|
return new FollowEntry(rs.getLong("id"), rs.getString("display_name"),
|
||||||
|
lastActivityAt == null ? null : lastActivityAt.toInstant());
|
||||||
|
};
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The user behind a name, when the profile is public and the user is neither blocked nor deleted.
|
||||||
|
*
|
||||||
|
* @return empty when no user or more than one user has the name
|
||||||
|
*/
|
||||||
|
public Optional<Long> findFollowable(String displayName) {
|
||||||
|
List<Long> ids = jdbc.queryForList("""
|
||||||
|
select a.user_id from account_name a
|
||||||
|
join public_user p on p.user_id = a.user_id
|
||||||
|
where lower(a.display_name) = lower(:name)
|
||||||
|
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
|
||||||
|
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
|
||||||
|
limit 2""", new MapSqlParameterSource("name", displayName), Long.class);
|
||||||
|
return ids.size() == 1 ? Optional.of(ids.get(0)) : Optional.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Public names that contain the query, case-insensitive; names that start with it come first. Same visibility
|
||||||
|
* rules as {@link #findFollowable}. Wildcards in the query match themselves.
|
||||||
|
*/
|
||||||
|
public List<String> searchPublicNames(String query, int limit) {
|
||||||
|
String escaped = LikePattern.escapeLower(query);
|
||||||
|
return jdbc.queryForList("""
|
||||||
|
select a.display_name from account_name a
|
||||||
|
join public_user p on p.user_id = a.user_id
|
||||||
|
where lower(a.display_name) like :pattern escape '\\'
|
||||||
|
and not exists (select 1 from blocked_user b where b.user_id = a.user_id)
|
||||||
|
and not exists (select 1 from deleted_user d where d.user_id = a.user_id)
|
||||||
|
order by (lower(a.display_name) like :prefix escape '\\') desc, lower(a.display_name)
|
||||||
|
limit :limit""", new MapSqlParameterSource("pattern", "%" + escaped + "%")
|
||||||
|
.addValue("prefix", escaped + "%").addValue("limit", limit), String.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks the visibility again in the same statement, so a user who went private in between gets no follower.
|
||||||
|
*
|
||||||
|
* @return true when a row was written
|
||||||
|
*/
|
||||||
|
public boolean insert(long userId, long followedUserId) {
|
||||||
|
return jdbc.update("""
|
||||||
|
insert into follow (user_id, followed_user_id)
|
||||||
|
select :userId, :followedUserId
|
||||||
|
where exists (select 1 from public_user where user_id = :followedUserId)
|
||||||
|
on conflict do nothing""", pair(userId, followedUserId)) > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public int countFollowing(long userId) {
|
||||||
|
Integer count = jdbc.queryForObject("select count(*) from follow where user_id = :userId",
|
||||||
|
new MapSqlParameterSource("userId", userId), Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With the first follower the cursor starts at "now", so the follower gets no old activity.
|
||||||
|
*/
|
||||||
|
public void startDigestIfFirst(long followedUserId) {
|
||||||
|
jdbc.update("""
|
||||||
|
insert into follow_digest (user_id, sync_started_at, status_change_id, wishlist_change_id)
|
||||||
|
select :id, (select sync_started_at from sync_result where user_id = :id),
|
||||||
|
coalesce((select max(id) from user_game_status_change where user_id = :id), 0),
|
||||||
|
coalesce((select max(id) from sync_change where user_id = :id and kind = 'WISHLIST'), 0)
|
||||||
|
where (select count(*) from follow where followed_user_id = :id) = 1
|
||||||
|
on conflict (user_id) do update
|
||||||
|
set sync_started_at = excluded.sync_started_at, status_change_id = excluded.status_change_id,
|
||||||
|
wishlist_change_id = excluded.wishlist_change_id""",
|
||||||
|
new MapSqlParameterSource("id", followedUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void deleteByFollowed(long userId, long followedUserId) {
|
||||||
|
jdbc.update("delete from follow where user_id = :userId and followed_user_id = :followedUserId",
|
||||||
|
pair(userId, followedUserId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only a row of the caller: the caller follows, or is followed.
|
||||||
|
*/
|
||||||
|
public void delete(long userId, long followId) {
|
||||||
|
jdbc.update("delete from follow where id = :id and (user_id = :userId or followed_user_id = :userId)",
|
||||||
|
new MapSqlParameterSource("id", followId).addValue("userId", userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<FollowEntry> findFollowing(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select f.id, a.display_name,
|
||||||
|
(select max(n.created_at) from inbox_note n
|
||||||
|
where n.user_id = f.user_id and n.subject_user_id = f.followed_user_id
|
||||||
|
and n.kind = 'DIGEST') as last_activity_at
|
||||||
|
from follow f
|
||||||
|
left join account_name a on a.user_id = f.followed_user_id
|
||||||
|
where f.user_id = :userId
|
||||||
|
order by lower(a.display_name), f.id
|
||||||
|
limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name of a follower with a private profile is left out.
|
||||||
|
*/
|
||||||
|
public List<FollowEntry> findFollowers(long userId) {
|
||||||
|
return jdbc.query("""
|
||||||
|
select f.id, case when p.user_id is not null then a.display_name end as display_name,
|
||||||
|
null::timestamptz as last_activity_at
|
||||||
|
from follow f
|
||||||
|
left join public_user p on p.user_id = f.user_id
|
||||||
|
left join account_name a on a.user_id = f.user_id
|
||||||
|
where f.followed_user_id = :userId
|
||||||
|
order by lower(case when p.user_id is not null then a.display_name end) nulls last, f.id
|
||||||
|
limit %d""".formatted(LIST_LIMIT), new MapSqlParameterSource("userId", userId), ENTRY);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static MapSqlParameterSource pair(long userId, long followedUserId) {
|
||||||
|
return new MapSqlParameterSource("userId", userId).addValue("followedUserId", followedUserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package core_service.modules.follow.service;
|
||||||
|
|
||||||
|
import core_service.modules.follow.domain.Cursor;
|
||||||
|
import core_service.modules.follow.domain.Digest;
|
||||||
|
import core_service.modules.follow.domain.DigestItems;
|
||||||
|
import core_service.modules.follow.repo.FollowDigestRepository;
|
||||||
|
import core_service.shared.event.LibraryActivityEvent;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.event.EventListener;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.PlatformTransactionManager;
|
||||||
|
import org.springframework.transaction.support.TransactionTemplate;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One inbox note per follower and batch. A batch is a sync with its achievement run, plus the hand changes since the
|
||||||
|
* last batch. The cursor moves in every case, so what the profile hides is never reported later. Each note also gets
|
||||||
|
* the lines behind its counts (status moves collapsed per game).
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
public class FollowDigestService {
|
||||||
|
|
||||||
|
private final FollowDigestRepository digestRepository;
|
||||||
|
private final TransactionTemplate transaction;
|
||||||
|
private final Duration quiet;
|
||||||
|
|
||||||
|
public FollowDigestService(FollowDigestRepository digestRepository, PlatformTransactionManager transactionManager,
|
||||||
|
@Value("${app.follow.quiet-minutes:10}") long quietMinutes) {
|
||||||
|
this.digestRepository = digestRepository;
|
||||||
|
this.transaction = new TransactionTemplate(transactionManager);
|
||||||
|
this.quiet = Duration.ofMinutes(quietMinutes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@EventListener
|
||||||
|
public void onLibraryActivity(LibraryActivityEvent event) {
|
||||||
|
digestOrLog(event.userId(), true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Only hand changes: it never takes a sync result, which may still wait for its achievements.
|
||||||
|
*/
|
||||||
|
@Scheduled(initialDelayString = "${app.follow.digest-ms:600000}", fixedDelayString = "${app.follow.digest-ms:600000}")
|
||||||
|
public void digestHandChanges() {
|
||||||
|
List<Long> userIds = digestRepository.findQuietHandChanges(Instant.now().minus(quiet));
|
||||||
|
for (long userId : userIds) {
|
||||||
|
digestOrLog(userId, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void digestOrLog(long userId, boolean withSync) {
|
||||||
|
try {
|
||||||
|
digest(userId, withSync);
|
||||||
|
} catch (RuntimeException e) {
|
||||||
|
log.warn("Follower digest of user {} failed", userId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void digest(long userId, boolean withSync) {
|
||||||
|
transaction.executeWithoutResult(tx -> {
|
||||||
|
Optional<Cursor> locked = digestRepository.lockCursor(userId);
|
||||||
|
if (locked.isEmpty()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
Cursor cursor = locked.get();
|
||||||
|
long upTo = withSync ? digestRepository.maxStatusChangeId(userId)
|
||||||
|
: digestRepository.maxHandStatusChangeId(userId, cursor.statusChangeId());
|
||||||
|
Digest digest = digestRepository.countHand(userId, cursor.statusChangeId(), upTo);
|
||||||
|
Instant syncStartedAt = cursor.syncStartedAt();
|
||||||
|
long wishlistUpTo = cursor.wishlistChangeId();
|
||||||
|
Optional<Instant> current = withSync ? digestRepository.findSyncStartedAt(userId) : Optional.empty();
|
||||||
|
boolean newSync = current.isPresent() && (syncStartedAt == null || current.get().isAfter(syncStartedAt));
|
||||||
|
DigestItems items = digestRepository.findHandItems(userId, cursor.statusChangeId(), upTo, newSync);
|
||||||
|
if (withSync) {
|
||||||
|
if (newSync) {
|
||||||
|
digest = digest.plus(digestRepository.countSync(userId));
|
||||||
|
items = items.plus(digestRepository.findSyncItems(userId));
|
||||||
|
syncStartedAt = current.get();
|
||||||
|
}
|
||||||
|
wishlistUpTo = digestRepository.maxWishlistChangeId(userId);
|
||||||
|
digest = digest.plus(new Digest(0, 0, 0,
|
||||||
|
digestRepository.countWishlist(userId, cursor.wishlistChangeId(), wishlistUpTo)));
|
||||||
|
items = items.plus(digestRepository.findWishlistItems(userId, cursor.wishlistChangeId(), wishlistUpTo));
|
||||||
|
}
|
||||||
|
List<String> sections = digestRepository.findPublicSections(userId);
|
||||||
|
digest = digest.visibleOn(sections);
|
||||||
|
items = items.visibleOn(sections).collapseStatus();
|
||||||
|
if (!digest.isEmpty()) {
|
||||||
|
List<Long> noteIds = digestRepository.insertDigestNotes(userId, digest);
|
||||||
|
digestRepository.insertDigestItems(noteIds, items);
|
||||||
|
}
|
||||||
|
digestRepository.moveCursor(userId, syncStartedAt, upTo, wishlistUpTo);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package core_service.modules.follow.service;
|
||||||
|
|
||||||
|
import core_service.modules.follow.domain.FollowResult;
|
||||||
|
import core_service.modules.follow.domain.Follows;
|
||||||
|
import core_service.modules.follow.domain.PlayerSearch;
|
||||||
|
import core_service.modules.follow.repo.FollowRepository;
|
||||||
|
import core_service.shared.security.RequiresRole;
|
||||||
|
import core_service.shared.security.Role;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Transactional
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class FollowService {
|
||||||
|
|
||||||
|
static final int MAX_FOLLOWING = 500;
|
||||||
|
static final int MIN_QUERY_LENGTH = 2;
|
||||||
|
|
||||||
|
private final FollowRepository followRepository;
|
||||||
|
private final PlayerSearchLimiter limiter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unknown, private, blocked and deleted names all answer NOT_FOUND, so a name cannot be probed.
|
||||||
|
*/
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public FollowResult follow(long userId, String displayName) {
|
||||||
|
Optional<Long> followed = followRepository.findFollowable(displayName);
|
||||||
|
if (followed.isEmpty()) {
|
||||||
|
return FollowResult.NOT_FOUND;
|
||||||
|
}
|
||||||
|
if (followed.get() == userId) {
|
||||||
|
return FollowResult.SELF;
|
||||||
|
}
|
||||||
|
if (followRepository.countFollowing(userId) >= MAX_FOLLOWING) {
|
||||||
|
return FollowResult.LIMIT;
|
||||||
|
}
|
||||||
|
if (followRepository.insert(userId, followed.get())) {
|
||||||
|
followRepository.startDigestIfFirst(followed.get());
|
||||||
|
}
|
||||||
|
return FollowResult.FOLLOWED;
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public void unfollow(long userId, String displayName) {
|
||||||
|
followRepository.findFollowable(displayName)
|
||||||
|
.ifPresent(followed -> followRepository.deleteByFollowed(userId, followed));
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
public void remove(long userId, long followId) {
|
||||||
|
followRepository.delete(userId, followId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Follows follows(long userId) {
|
||||||
|
return new Follows(followRepository.findFollowing(userId), followRepository.findFollowers(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A query shorter than two characters finds nothing and does not count against the limit.
|
||||||
|
*/
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public PlayerSearch searchPlayers(long userId, String query) {
|
||||||
|
String stripped = query.strip();
|
||||||
|
if (stripped.length() < MIN_QUERY_LENGTH) {
|
||||||
|
return PlayerSearch.of(List.of());
|
||||||
|
}
|
||||||
|
if (!limiter.allow(userId)) {
|
||||||
|
return PlayerSearch.tooMany();
|
||||||
|
}
|
||||||
|
return PlayerSearch.of(followRepository.searchPublicNames(stripped, FollowRepository.SEARCH_LIMIT));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package core_service.modules.follow.service;
|
||||||
|
|
||||||
|
import io.github.resilience4j.ratelimiter.RateLimiter;
|
||||||
|
import io.github.resilience4j.ratelimiter.RateLimiterConfig;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.concurrent.ConcurrentHashMap;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Searches per user and minute. One limiter per user who searched since boot stays in memory: small, and bounded by
|
||||||
|
* the number of users. The limit holds per instance.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class PlayerSearchLimiter {
|
||||||
|
|
||||||
|
private final RateLimiterConfig config;
|
||||||
|
private final ConcurrentHashMap<Long, RateLimiter> limiters = new ConcurrentHashMap<>();
|
||||||
|
|
||||||
|
public PlayerSearchLimiter(@Value("${app.follow.search-per-minute:30}") int perMinute) {
|
||||||
|
this.config = RateLimiterConfig.custom()
|
||||||
|
.limitForPeriod(perMinute)
|
||||||
|
.limitRefreshPeriod(Duration.ofMinutes(1))
|
||||||
|
.timeoutDuration(Duration.ZERO)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean allow(long userId) {
|
||||||
|
return limiters.computeIfAbsent(userId, id -> RateLimiter.of("player-search-" + id, config)).acquirePermission();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,23 @@ public final class IgdbResponses {
|
|||||||
@JsonProperty("game_modes") java.util.List<Named> gameModes
|
@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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
public record Edition(
|
public record Edition(
|
||||||
@JsonProperty("id") Long id,
|
@JsonProperty("id") Long id,
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ import org.springframework.stereotype.Service;
|
|||||||
import org.springframework.web.client.HttpClientErrorException;
|
import org.springframework.web.client.HttpClientErrorException;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.client.RestClient;
|
||||||
|
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
|
import java.time.LocalDate;
|
||||||
import java.time.ZoneOffset;
|
import java.time.ZoneOffset;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Arrays;
|
import java.util.Arrays;
|
||||||
@@ -21,12 +23,14 @@ import java.util.Comparator;
|
|||||||
import java.util.Collection;
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
import java.util.LinkedHashSet;
|
import java.util.LinkedHashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.TreeSet;
|
import java.util.TreeSet;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
|
|
||||||
@@ -48,6 +52,17 @@ public class IgdbApiService {
|
|||||||
*/
|
*/
|
||||||
public record GameTags(List<String> genres, List<String> themes, List<String> modes) {}
|
public record 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}
|
* @param sameGameId the IGDB game all editions, remasters and ports of one game share, see {@link #sameGameId}
|
||||||
*/
|
*/
|
||||||
@@ -63,6 +78,9 @@ public class IgdbApiService {
|
|||||||
*/
|
*/
|
||||||
public record Game(long igdbId, String name, Integer year, String imageUrl, List<String> platforms, Long steamAppid) {}
|
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.
|
// 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_FETCH_LIMIT = 50;
|
||||||
private static final int SEARCH_LIMIT = 20;
|
private static final int SEARCH_LIMIT = 20;
|
||||||
@@ -155,6 +173,65 @@ public class IgdbApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return the series and companies of the Steam apps IGDB links to a game, empty lists when that game has none;
|
||||||
|
* apps IGDB does not link are missing. An app linked to several games gets the credits of all of them. Empty when
|
||||||
|
* the adapter is off or a request failed.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, Credits>> getCredits(Collection<Long> steamAppids) {
|
||||||
|
if (!isEnabled() || steamAppids.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> appids = List.copyOf(steamAppids);
|
||||||
|
Map<Long, Credits> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < appids.size(); from += BATCH) {
|
||||||
|
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
||||||
|
creditsOf(steamByIgdb.keySet()).forEach((igdbId, credits) ->
|
||||||
|
steamByIgdb.get(igdbId).forEach(appid -> result.merge(appid, credits, IgdbApiService::union)));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for credits", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #getCredits}, but for IGDB game ids.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, Credits>> getCreditsByGameIds(Collection<Long> igdbIds) {
|
||||||
|
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
|
Map<Long, Credits> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
|
result.putAll(creditsOf(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for credits", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Credits union(Credits a, Credits b) {
|
||||||
|
Map<Long, Series> series = new LinkedHashMap<>();
|
||||||
|
Stream.concat(a.series().stream(), b.series().stream()).forEach(s -> series.putIfAbsent(s.id(), s));
|
||||||
|
return new Credits(List.copyOf(series.values()),
|
||||||
|
byCompany(Stream.concat(a.companies().stream(), b.companies().stream())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// One entry per company, its roles OR-ed.
|
||||||
|
private static List<Company> byCompany(Stream<Company> companies) {
|
||||||
|
Map<Long, Company> byId = new LinkedHashMap<>();
|
||||||
|
companies.forEach(c -> byId.merge(c.id(), c, (x, y) ->
|
||||||
|
new Company(x.id(), x.name(), x.developer() || y.developer(), x.publisher() || y.publisher())));
|
||||||
|
return List.copyOf(byId.values());
|
||||||
|
}
|
||||||
|
|
||||||
private static GameTags union(GameTags a, GameTags b) {
|
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()));
|
return new GameTags(union(a.genres(), b.genres()), union(a.themes(), b.themes()), union(a.modes(), b.modes()));
|
||||||
}
|
}
|
||||||
@@ -251,8 +328,8 @@ public class IgdbApiService {
|
|||||||
Map<Long, Set<Integer>> result = new HashMap<>();
|
Map<Long, Set<Integer>> result = new HashMap<>();
|
||||||
for (int from = 0; from < appids.size(); from += BATCH) {
|
for (int from = 0; from < appids.size(); from += BATCH) {
|
||||||
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
||||||
yearsOf(steamByIgdb.keySet()).forEach((igdbId, year) -> steamByIgdb.get(igdbId)
|
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
|
||||||
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(year)));
|
.forEach(appid -> result.computeIfAbsent(appid, key -> new TreeSet<>()).add(date.getYear())));
|
||||||
}
|
}
|
||||||
return Optional.of(result);
|
return Optional.of(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -272,8 +349,8 @@ public class IgdbApiService {
|
|||||||
List<Long> ids = List.copyOf(igdbIds);
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
Map<Long, Set<Integer>> result = new HashMap<>();
|
Map<Long, Set<Integer>> result = new HashMap<>();
|
||||||
for (int from = 0; from < ids.size(); from += BATCH) {
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
yearsOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
|
datesOf(ids.subList(from, Math.min(from + BATCH, ids.size())))
|
||||||
.forEach((igdbId, year) -> result.put(igdbId, Set.of(year)));
|
.forEach((igdbId, date) -> result.put(igdbId, Set.of(date.getYear())));
|
||||||
}
|
}
|
||||||
return Optional.of(result);
|
return Optional.of(result);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -282,6 +359,62 @@ public class IgdbApiService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The first release date per Steam appid (UTC); the earliest when an appid belongs to several IGDB games. An app
|
||||||
|
* IGDB has no date for is missing. Empty when the adapter is off or IGDB failed.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, LocalDate>> getReleaseDates(Collection<Long> steamAppids) {
|
||||||
|
if (!isEnabled() || steamAppids.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> appids = List.copyOf(steamAppids);
|
||||||
|
Map<Long, LocalDate> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < appids.size(); from += BATCH) {
|
||||||
|
Map<Long, List<Long>> steamByIgdb = steamAppidsByIgdbGame(appids.subList(from, Math.min(from + BATCH, appids.size())));
|
||||||
|
datesOf(steamByIgdb.keySet()).forEach((igdbId, date) -> steamByIgdb.get(igdbId)
|
||||||
|
.forEach(appid -> result.merge(appid, date, (a, b) -> a.isBefore(b) ? a : b)));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for release dates", e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Like {@link #getReleaseDates}, but for IGDB game ids.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, LocalDate>> getReleaseDatesByGameIds(Collection<Long> igdbIds) {
|
||||||
|
return inBatches(igdbIds, "release dates", this::datesOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The name and release year of IGDB games by id. Games IGDB does not know, or without a name, are missing. Empty
|
||||||
|
* when the adapter is off, nothing was asked or a request failed.
|
||||||
|
*/
|
||||||
|
public Optional<Map<Long, NameAndYear>> getNamesAndYears(Collection<Long> igdbIds) {
|
||||||
|
return inBatches(igdbIds, "game names", this::namesAndYearsOf);
|
||||||
|
}
|
||||||
|
|
||||||
|
private <T> Optional<Map<Long, T>> inBatches(Collection<Long> igdbIds, String what,
|
||||||
|
Function<List<Long>, Map<Long, T>> batchQuery) {
|
||||||
|
if (!isEnabled() || igdbIds.isEmpty()) {
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
List<Long> ids = List.copyOf(igdbIds);
|
||||||
|
Map<Long, T> result = new HashMap<>();
|
||||||
|
for (int from = 0; from < ids.size(); from += BATCH) {
|
||||||
|
result.putAll(batchQuery.apply(ids.subList(from, Math.min(from + BATCH, ids.size()))));
|
||||||
|
}
|
||||||
|
return Optional.of(result);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error communicating with IGDB for {}", what, e);
|
||||||
|
return Optional.empty();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
|
* Games whose name matches, without editions and other versions. Empty when the adapter is off or IGDB failed.
|
||||||
*/
|
*/
|
||||||
@@ -426,6 +559,44 @@ public class IgdbApiService {
|
|||||||
return result;
|
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) {
|
private Map<Long, Edition> editionsOf(Collection<Long> igdbIds) {
|
||||||
if (igdbIds.isEmpty()) {
|
if (igdbIds.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
@@ -443,25 +614,43 @@ public class IgdbApiService {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
private Map<Long, Integer> yearsOf(Collection<Long> igdbIds) {
|
private Map<Long, LocalDate> datesOf(Collection<Long> igdbIds) {
|
||||||
if (igdbIds.isEmpty()) {
|
if (igdbIds.isEmpty()) {
|
||||||
return Map.of();
|
return Map.of();
|
||||||
}
|
}
|
||||||
String gameIds = igdbIds.stream().map(String::valueOf).collect(Collectors.joining(","));
|
IgdbResponses.Game[] games = gamesById(igdbIds, "first_release_date");
|
||||||
IgdbResponses.Game[] games = query("/games",
|
Map<Long, LocalDate> result = new HashMap<>();
|
||||||
"fields first_release_date; where id = (" + gameIds + "); limit " + LIMIT + ";",
|
|
||||||
IgdbResponses.Game[].class);
|
|
||||||
Map<Long, Integer> result = new HashMap<>();
|
|
||||||
for (IgdbResponses.Game game : games) {
|
for (IgdbResponses.Game game : games) {
|
||||||
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
if (game.id() != null && game.firstReleaseDate() != null && igdbIds.contains(game.id())) {
|
||||||
result.put(game.id(), year(game.firstReleaseDate()));
|
result.put(game.id(), date(game.firstReleaseDate()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
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) {
|
private static int year(long unixSeconds) {
|
||||||
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).getYear();
|
return date(unixSeconds).getYear();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static LocalDate date(long unixSeconds) {
|
||||||
|
return Instant.ofEpochSecond(unixSeconds).atZone(ZoneOffset.UTC).toLocalDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<String> names(List<IgdbResponses.Named> named) {
|
private static List<String> names(List<IgdbResponses.Named> named) {
|
||||||
@@ -516,7 +705,8 @@ public class IgdbApiService {
|
|||||||
.uri(path)
|
.uri(path)
|
||||||
.header("Client-ID", clientId)
|
.header("Client-ID", clientId)
|
||||||
.header("Authorization", "Bearer " + token)
|
.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)
|
.accept(MediaType.APPLICATION_JSON)
|
||||||
.body(body)
|
.body(body)
|
||||||
.retrieve()
|
.retrieve()
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package core_service.modules.inbox.api;
|
||||||
|
|
||||||
|
import core_service.modules.inbox.api.dto.InboxDto;
|
||||||
|
import core_service.modules.inbox.api.dto.NoteDetailsDto;
|
||||||
|
import core_service.modules.inbox.domain.Inbox;
|
||||||
|
import core_service.modules.inbox.service.InboxService;
|
||||||
|
import core_service.shared.api.ErrorResponse;
|
||||||
|
import core_service.shared.security.UserPrincipal;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PutMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/inbox")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InboxController {
|
||||||
|
|
||||||
|
private final InboxService inboxService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ResponseEntity<InboxDto> inbox(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
Inbox inbox = inboxService.inbox(principal.getIdAsLong());
|
||||||
|
return ResponseEntity.ok(new InboxDto(inbox.notes().stream()
|
||||||
|
.map(note -> new InboxDto.Note(note.id(), note.kind().name(), note.subjectName(), note.profileName(), note.newGames(),
|
||||||
|
note.achievements(), note.statusChanges(), note.wishlistAdds(), note.createdAt(), note.read(), note.details()))
|
||||||
|
.toList(), inbox.unread()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}/details")
|
||||||
|
public ResponseEntity<?> details(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id) {
|
||||||
|
return inboxService.details(principal.getIdAsLong(), id)
|
||||||
|
.<ResponseEntity<?>>map(details -> ResponseEntity.ok(new NoteDetailsDto(details.stream()
|
||||||
|
.map(detail -> new NoteDetailsDto.Detail(detail.appid(), detail.kind().name(), detail.gameName(),
|
||||||
|
detail.itemName(), detail.fromStatus(), detail.toStatus()))
|
||||||
|
.toList())))
|
||||||
|
.orElseGet(() -> ResponseEntity.status(HttpStatus.NOT_FOUND)
|
||||||
|
.body(new ErrorResponse("NOTE_NOT_FOUND", "No such note")));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/read")
|
||||||
|
public ResponseEntity<Void> markAllRead(@AuthenticationPrincipal UserPrincipal principal) {
|
||||||
|
inboxService.markAllRead(principal.getIdAsLong());
|
||||||
|
return ResponseEntity.noContent().build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package core_service.modules.inbox.api.dto;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param unread all unread notes, also those beyond the newest 50 in {@code results}
|
||||||
|
*/
|
||||||
|
public record InboxDto(List<Note> results, int unread) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param kind DIGEST, VISIBILITY, PSN_EXPIRING or PSN_EXPIRED (the PSN kinds have no displayName)
|
||||||
|
* @param displayName the name of the followed user when the note was written; never a user id
|
||||||
|
* @param profileName the subject's current name while the profile is public, else null; never an id
|
||||||
|
* @param details the note can be opened: it has detail rows and the subject is still followed and public
|
||||||
|
*/
|
||||||
|
public record Note(long id, String kind, String displayName, String profileName, int newGames, int achievements, int statusChanges,
|
||||||
|
int wishlistAdds, Instant createdAt, boolean read, boolean details) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package core_service.modules.inbox.api.dto;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public record NoteDetailsDto(List<Detail> results) {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param appid the game key as stored in the note; may be a merged key
|
||||||
|
* @param kind NEW_GAME, ACHIEVEMENT, STATUS or WISHLIST
|
||||||
|
* @param itemName ACHIEVEMENT only: the achievement or trophy name
|
||||||
|
* @param fromStatus STATUS only
|
||||||
|
* @param toStatus STATUS only
|
||||||
|
*/
|
||||||
|
public record Detail(long appid, String kind, String gameName, String itemName, String fromStatus, String toStatus) {
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param unread all unread notes, also those beyond the list
|
||||||
|
*/
|
||||||
|
public record Inbox(List<InboxNote> notes, int unread) {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
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, 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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package core_service.modules.inbox.domain;
|
||||||
|
|
||||||
|
public enum NoteKind {
|
||||||
|
DIGEST,
|
||||||
|
VISIBILITY,
|
||||||
|
/** About the reader's own PlayStation link; subject is the reader. */
|
||||||
|
PSN_EXPIRING,
|
||||||
|
PSN_EXPIRED
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
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
|
||||||
|
public class InboxRepository {
|
||||||
|
|
||||||
|
private final NamedParameterJdbcTemplate jdbc;
|
||||||
|
|
||||||
|
public List<InboxNote> findNewest(long userId, int limit) {
|
||||||
|
return jdbc.query("""
|
||||||
|
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.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.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) {
|
||||||
|
Integer count = jdbc.queryForObject("select count(*) from inbox_note where user_id = :userId and read_at is null",
|
||||||
|
new MapSqlParameterSource("userId", userId), Integer.class);
|
||||||
|
return count != null ? count : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markAllRead(long userId) {
|
||||||
|
jdbc.update("update inbox_note set read_at = now() where user_id = :userId and read_at is null",
|
||||||
|
new MapSqlParameterSource("userId", userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read and unread notes alike.
|
||||||
|
*
|
||||||
|
* @return how many notes were deleted
|
||||||
|
*/
|
||||||
|
public int deleteBefore(Instant cutoff) {
|
||||||
|
return jdbc.update("delete from inbox_note where created_at < :cutoff",
|
||||||
|
new MapSqlParameterSource("cutoff", Timestamp.from(cutoff)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.scheduling.annotation.Scheduled;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@Slf4j
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class InboxService {
|
||||||
|
|
||||||
|
static final int LIST_SIZE = 50;
|
||||||
|
|
||||||
|
private final InboxRepository inboxRepository;
|
||||||
|
|
||||||
|
@Value("${app.inbox.keep-days:90}")
|
||||||
|
private long keepDays;
|
||||||
|
|
||||||
|
@RequiresRole(Role.USER)
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Inbox inbox(long userId) {
|
||||||
|
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) {
|
||||||
|
inboxRepository.markAllRead(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Scheduled(cron = "${app.inbox.purge-cron:0 15 3 * * *}", zone = "${app.library.zone:Europe/Berlin}")
|
||||||
|
public void purgeOld() {
|
||||||
|
int deleted = inboxRepository.deleteBefore(Instant.now().minus(Duration.ofDays(keepDays)));
|
||||||
|
if (deleted > 0) {
|
||||||
|
log.info("Deleted {} inbox notes older than {} days", deleted, keepDays);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -62,7 +62,8 @@ public class GameImportController {
|
|||||||
@PostMapping("/{id}/save")
|
@PostMapping("/{id}/save")
|
||||||
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
|
public ResponseEntity<?> save(@AuthenticationPrincipal UserPrincipal principal, @PathVariable("id") long id,
|
||||||
@Valid @RequestBody ImportSaveRequest request) {
|
@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 DONE -> ResponseEntity.accepted().build();
|
||||||
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
case NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body(NOT_FOUND);
|
||||||
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
case WRONG_STATUS, ALREADY_MATCHING -> ResponseEntity.status(HttpStatus.CONFLICT)
|
||||||
|
|||||||
Loaded 100 of 380 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user