feat(library): show play per platform on game detail

Store PSN play per title. A PS title on a Steam row keeps its minutes.
Steam rtime_last_played now fills last played.
Dropped? questions read the newest PSN last played too.
This commit is contained in:
lobo committed 2026-10-09 13:55:57 +02:00
1 parent 413381d404
commit 1ff1e061c7
30 files changed
+707 -33

No files matched your search

+26 -9
View File
@@ -218,7 +218,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `POST` | `/api/rewards/purchases` (`{item}`; `200` wallet, `409 ALREADY_OWNED` / `NOT_ENOUGH_POINTS` / `NOT_FOR_SALE`) | JWT |
| `GET` | `/api/rewards/progress/{track}/games?page=`, `/api/rewards/history/{kind}/{ref}/games?page=` (the games behind a track or a `MONTH` / `STREAK` bonus; `400 NOT_A_GAME_LIST`) | JWT |
| `PUT` | `/api/rewards/equipped/{slot}` (`{item}`, null = take off; `200` wallet, `409 NOT_OWNED`, `400 WRONG_SLOT`) | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual; `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `GET` | `/api/library/games` (one row per set, the counted edition, with `platforms[]` of all its editions: Steam, PlayStation, then manual, and `totalPlaytimeMinutes` (all platforms; `playtimeMinutes` stays the counted edition's own); `name` matches any edition; `source=STEAM,PSN` keeps rows with a platform of that source, a PlayStation title on a Steam row counts as `PSN`; ignored with `hidden=true`, which lists every hidden row; `400` on an unknown source; an old `hideDuplicates` param is ignored), `/api/library/games/{appid}` | JWT |
| `PATCH` | `/api/library/games/{appid}/backlog` (only the sent fields; `null` clears one) | JWT |
| `PUT`, `DELETE` | `/api/library/games/{appid}/corrections/{field}` (`{value}`; `name`, `image`, `speedrun`, `moods`, `endless`, `genres`, `time_to_beat` = `{normallyMinutes, completelyMinutes}`; `204`, `400 INVALID_CORRECTION`) | JWT, AUDITOR |
| `GET` | `/api/library/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 |
@@ -244,7 +244,7 @@ ArchUnit role rules (`RoleAnnotationRules`) and `src/test/resources/contract/aut
| `GET`, `DELETE` | `/api/library/psn-account` (`{enabled, linked, linkedAt, expiresAt, reviewImportId}`: the PSN import waiting for review, else null), unlink (`204`, deletes the stored token) | JWT |
| `GET`, `DELETE` | `/api/library/imports/{id}/undo-check` (`{games, changed}`), `/api/library/imports/{id}/games` (undo; `204`, `409 IMPORT_NOT_SAVED`) | JWT |
| `POST` | `/api/library/sync` | JWT |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only) | JWT |
| `GET` | `/api/library/games/{appid}` (detail; slice 3: `achievements[]` also hold trophies with `kind` `STEAM`\|`PSN`, `setId`, `trophyType`, `group`, `rarity`; new `achievementSets[]` = `{id, kind, platform, name, total, unlocked, perfect, visible, listed}`, Steam first, then PS5 before PS4; `hasAchievements` = any set; the top-level counts stay Steam only; `platforms[]` = `{source, platform, appid, playtimeMinutes, lastPlayedAt, achievementsUnlocked, achievementsTotal, perfect}` one line per platform of the entry, Steam, PS5, PS4, manual, each with its own progress, `appid` = the edition whose detail lists them; `totalPlaytimeMinutes` = their sum) | JWT |
| `POST` | `/api/library/broken-images` (`{appid, kind}`, kind `COVER` or `ACHIEVEMENT_ICONS`; `204`) | JWT |
| `GET` | `/api/library/backlog?limit=&released=` (`{games}` in the user's order; `limit` 1-20, none = all) | JWT |
| `PUT` | `/api/library/backlog` (`{appids}` = the whole order; `204`) | JWT |
@@ -385,7 +385,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
4. The worker loads the wishlist and the owned games from Steam. It updates `steam_game` and `user_game`, adds
the playtime growth of the day to `user_game_playtime_day`, and soft-deletes Steam games the user no longer
owns. The growth counts at most Steam's playtime of the last two weeks, so a game seen for the first time brings
no old playtime, and one game gets at most 24 hours per day. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
no old playtime, and one game gets at most 24 hours per day. Steam's `rtime_last_played` (unix seconds, 0 = never)
is stored in `user_game.last_played_at`; a missing or zero value keeps the old one. A BACKLOG game with new playtime becomes PLAYING, unless the user chose its status. When Steam returns no
games, the library stays as it is.
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.
@@ -394,7 +395,8 @@ Inside a module: `api` (controllers, DTOs), `service` (business logic), `domain`
FINISHED for a game whose ending achievement is unlocked while the user never chose its status.
The PSN step (`PsnSyncStep`, then `PsnTrophyStep`, see "PlayStation link") runs after the Steam fetch and before
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`.
the transaction; its `ADDED`, `PLAYTIME` and trophy `ACHIEVEMENTS` changes join the same `sync_result`. It also saves
the play of every title (`PsnTitleRepository.savePlays`, "save title play" in the diagram below).
The frontend polls `GET /api/library/sync-status` until the run ends.
@@ -412,6 +414,7 @@ sequenceDiagram
participant F as FollowDigestService
participant D as Db
W->>P: run (titles, trophy summaries, changed lists)
P->>D: save title play (user_psn_title)
P-->>W: ADDED, PLAYTIME, ACHIEVEMENTS (trophy gains)
W->>D: sync_result, sync_change (ADDED, STATUS, WISHLIST, PSN changes)
W-->>A: LibrarySyncedEvent
@@ -560,12 +563,22 @@ IGDB id; remakes (8) keep their own id. `null` when IGDB does not know the game.
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), `sumPlaytime` and the
community stats of the landing page. Card playtime is the counted edition's own; the set total comes with the
detail page. Pile Points pay a set once (see "Pile Points"), and the
community stats of the landing page. The card playtime is the set total (`entryPlaytimes`, 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.
- **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 card total
(`GameLinkRepository.entryPlaytimes`) follows the same rule in SQL, a test compares both. 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), the stats sums (most played, total
playtime, moon trips), the playtime sort, and rewards (Steam only). Until a user's next sync after V44, PSN-on-Steam
lines are missing and Steam's last played is empty.
- The game detail has `linked` and `editions` (the user's other listed games of the set); a game row has `editions`
(size of the set in the user's library, `null` below 2) and `platforms` (`source` + `platform` per edition, for
the icons of the card).
(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
@@ -577,7 +590,9 @@ flowchart LR
Counted --> Member["user_game_entry_member"]
Member --> Platform["user_game_entry_platform"]
Platform -->|"platforms[]"| Card["library card, lists"]
Member -->|"summed playtime"| Card
Member -->|"totalPlaytimeMinutes"| Card
Title["user_psn_title play (V44)"] -->|"psn_title_play"| Member
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"]
@@ -694,6 +709,8 @@ project ships it; all values in `app.psn.*`). `PsnAccountService` in `library` h
spelling or platform that the user owns is only linked. Everything else goes to the review: a READY `PSN` import with
the matched rows is created, or the rows are appended to the one that waits (`reviewImportId`). The review page is
the import page; saving a PSN import links the checked lines and dismisses the unchecked ones for good.
- Every `LINKED` or `STEAM_OWNED` title also keeps its own platform, minutes and last played in `user_psn_title` (V44),
so the detail shows PlayStation play next to Steam's on a shared row. The Steam row's own playtime stays Steam's.
- A `LINKED` title updates its row (`source = PSN`, not removed): playtime, `last_played_at`, `PLAYTIME` changes and the
playtime history; `playtime_2weeks` is the sum of the history of the last 14 days. A game the user deleted stays
`LINKED` and is not added again. A game also owned on Steam stays one row (the Steam row wins).