feat(psn): sync PlayStation titles in the library sync

Exact IGDB matches are added; unsure titles wait in a review import.
Playtime and last played come from PSN: summed per game, not editable.
PSN games give no Pile Points. Users with only a PSN link sync too
(V39 drops steam_id NOT NULL). Linking starts a sync, or schedules one
for the end of the cooldown. Inbox notes 2 days before the PSN link
ends and when it has ended.
This commit is contained in:
lobo committed 2026-10-08 08:15:37 +02:00
1 parent 7ac887ed5a
commit f5ca7237a9
54 files changed
+2230 -313

No files matched your search

+40 -20
View File
@@ -137,8 +137,9 @@ To Be Played is split into three repositories:
- **Time to beat**: story, extras and completionist hours from the IGDB API. 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) and one click imports the played PS4/PS5 titles with playtime through the import pipeline. See
"PlayStation link". Off without `APP_PSN_TOKEN_KEY`.
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. See "PlayStation link". Off without
`APP_PSN_TOKEN_KEY`.
- **Public profile data**: totals, status counts, games in progress and finished games with rating, perfect games
and average completion (the numbers count a linked set once; the lists keep every edition). Readable only
with a profile ticket issued by Auth. Never returns notes, priority or the Steam ID. `stats` holds only the stat
@@ -211,9 +212,8 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/imports` (`{imports}`, the last 20), `/api/library/imports/{id}` (status, progress counts, `ahead` while it waits in a queue, `attempts`; the rows with their candidates while `READY`) | JWT |
| `POST` | `/api/library/imports/{id}/save` (`{rows: [{lineNo, igdbId, platform, status, playtimeMinutes}]}`; `202`, then poll until `SAVED`; `409 IMPORT_NOT_READY`) | JWT |
| `POST` | `/api/library/imports/{id}/retry` (a `FAILED` import; `202`, `409 IMPORT_NOT_FAILED` / `IMPORT_RUNNING`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt}`, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt}`), unlink (`204`, deletes the stored token) | JWT |
| `POST` | `/api/library/psn-account/imports` (reads the played titles; `202 {id}` like an import, `204` no titles, `404 PSN_NOT_LINKED`, `409 PSN_RELINK` / `IMPORT_RUNNING`, `502 PSN_ERROR`, `503 PSN_OFF` / `IGDB_OFF`) | JWT |
| `POST` | `/api/library/psn-account` (`{npsso}`; `200 {enabled, linked, linkedAt, expiresAt, reviewImportId}` and a sync starts, `400 NPSSO_INVALID`, `502 PSN_ERROR`, `503 PSN_OFF`) | JWT |
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
@@ -223,7 +223,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET` | `/api/library/stats?groups=` (`OVERVIEW`, `PILE`, `TOP_GAMES`, `TASTE`, `HABITS`, `FINISHED`, `TREND`, `PLATFORMS`; none = all) | JWT |
| `GET` | `/api/library/stats/games?key=&arg=&page=` (the games behind a stats number; `400 BAD_ARG`) | JWT |
| `GET` | `/api/library/achievements/summary` | 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) | JWT |
| `GET` | `/api/public/profiles/library` | profile ticket |
| `GET` | `/api/public/profiles/library/stats/games?key=&arg=&page=` (`403 NOT_SHARED`, `400 BAD_ARG`) | profile ticket |
| `GET` | `/api/public/profiles/rewards` (`{equipped}`, the worn looks) | profile ticket |
@@ -287,7 +287,7 @@ flowchart LR
Igdb -->|"OAuth token"| Twitch["Twitch"]
Speedrun --> SpeedrunApi["speedrun.com API"]
Psn -->|"NPSSO sign-in, played titles"| PsnApi["PlayStation API (unofficial)"]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user"| Db[("PostgreSQL tbp_core")]
LibRepo -->|"own tables; picker also follow, public_user, account_name, blocked_user, deleted_user; inbox_note (PSN expiry notes)"| Db[("PostgreSQL tbp_core")]
Stats -->|"listed_user_game, counted_user_game, game_mood, coming_game, user_game_status_change, deleted_user, blocked_user, listed_user, account_name, game_alias"| Db
Follow -->|"follow, follow_digest, inbox_note, public_user, sync_change, user_game_status_change, account_name"| Db
Inbox --> Db
@@ -297,7 +297,7 @@ flowchart LR
| Module | Content |
|---|---|
| `library` | Sync, backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
| `library` | Sync (Steam and PSN), backlog, manual games, imports, the PlayStation link, playtime history, wishlist, achievements, public profile data |
| `stats` | Public numbers for the landing page |
| `version` | `GET /api/version`: commit and build time of the running image |
| `follow` | Follows (`follow`), the digest cursor per followed user (`follow_digest`) and `FollowDigestService`: one note per follower and batch, from `sync_change` and the hand rows of `user_game_status_change` |
@@ -595,19 +595,38 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
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 import answers `PSN_RELINK`.
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. An expired link answers `409 PSN_RELINK`; the user
pastes a new NPSSO. The Settings page shows the date.
- **Import** (`POST /api/library/psn-account/imports`): 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). The lines go through
`GameImportService.createLines` as a `PSN` import: IGDB match, review, save, undo. Saved rows have `source = PSN` and
`platform` PS5 or PS4. A game also owned on Steam stays one row (the Steam row wins); its PSN playtime is not added.
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.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).
- 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. Repeating sync and trophies are not built.
move it back. One sync costs 2 PlayStation calls (token and one page of titles), 8 s at the server-wide limit; the
nightly loop is sequential. Trophies are not built.
## Run locally
@@ -642,9 +661,10 @@ What the `local` profile does:
- The browser still loads game pictures from the Steam and IGDB image servers; offline they stay gray.
- "Sign in through Steam" still opens the real Steam page (no key needed). The dev user is already linked.
- A request to WireMock that has no recording answers 404 and shows up in Core's log.
- PlayStation: paste `local-npsso` in the PlayStation card of the settings (any other value is refused). The import then
lists 4 games: Ghost of Yōtei, Mega Man Legacy Collection, Ghost of Tsushima (PS4 and PS5 version as one line) and Monster
Hunter Wilds, which IGDB's recording does not know: the review shows an unrelated guess, not checked. The local profile has a fixed token key.
- 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.
- 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.