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).
@@ -16,7 +16,7 @@ public record GameDetailDto(
int playtime2Weeks,
Instant firstSeenAt,
Instant lastSeenAt,
// from PlayStation; null for Steam rows
// from PlayStation or Steam; null when not known
Instant lastPlayedAt,
GameStatus status,
Integer rating,
@@ -57,7 +57,11 @@ public record GameDetailDto(
// see GameDto#comingOn
LocalDate comingOn,
// Steam's set first, then the PlayStation sets, PS5 before PS4; the counts above are Steam's only
List<AchievementSetDto> achievementSets
List<AchievementSetDto> achievementSets,
// one line per platform of the entry, Steam first; empty for a hidden game
List<PlatformPlayDto> platforms,
// the sum of the platform lines; the counts above and playtimeMinutes stay this edition's own
int totalPlaytimeMinutes
) {
/**
@@ -19,6 +19,8 @@ import java.util.List;
* @param coming the game is not released yet and nobody played it; it does not count on the pile
* @param comingOn the release date of a coming game; null for released games and for "coming soon"
* @param platforms the platforms of every edition of the entry, Steam first; empty for a hidden game
* @param totalPlaytimeMinutes the playtime of every platform of the entry; {@code playtimeMinutes} stays the counted
* edition's own
*/
public record GameDto(
Long appid,
@@ -39,5 +41,6 @@ public record GameDto(
Integer editions,
boolean coming,
LocalDate comingOn,
List<PlatformDto> platforms
List<PlatformDto> platforms,
int totalPlaytimeMinutes
) {}
@@ -0,0 +1,18 @@
package core_service.modules.library.api.dto;
import core_service.modules.library.domain.GameSource;
import java.time.Instant;
/**
* One platform line of a game's detail.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line belongs to; its own detail page lists the achievements
* @param lastPlayedAt null when not known
* @param achievementsUnlocked null when the platform has no achievements for the user
* @param achievementsTotal null when the platform has no achievements for the user
* @param perfect every achievement unlocked, or every trophy set perfect
*/
public record PlatformPlayDto(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt,
Integer achievementsUnlocked, Integer achievementsTotal, boolean perfect) {}
@@ -0,0 +1,54 @@
package core_service.modules.library.domain;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
/**
* One platform's play of a game entry: what the detail lists and the card totals. The SQL of
* {@code GameLinkRepository#entryPlaytimes} follows the same rule; a test pins both.
*
* @param platform e.g. "PS5"; null when the edition has none
* @param appid the edition the line comes from
* @param lastPlayedAt null when not known
*/
public record PlatformPlay(GameSource source, String platform, long appid, int playtimeMinutes, Instant lastPlayedAt) {
private static final Comparator<PlatformPlay> ORDER = Comparator
.comparingInt((PlatformPlay line) -> line.source() == GameSource.STEAM ? 0
: line.source() == GameSource.PSN ? 1 : 2)
.thenComparing(PlatformPlay::platform, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparingLong(PlatformPlay::appid);
/**
* A Steam row is one line; a PlayStation row is its titles, or itself while it has no stored title
* play; a manual row is replaced by the titles of its own platform; every title with stored play on any row adds its own PlayStation line.
*
* @param titles the stored play of the titles linked to the rows; other keys are ignored
*/
public static List<PlatformPlay> lines(List<UserGame> rows, List<TitlePlay> titles) {
List<PlatformPlay> lines = new ArrayList<>();
for (UserGame row : rows) {
List<TitlePlay> own = titles.stream().filter(title -> row.getAppid().equals(title.appid())).toList();
boolean replaced = switch (row.getSource()) {
case PSN -> !own.isEmpty();
case MANUAL -> own.stream().anyMatch(title -> row.getPlatform() != null
&& row.getPlatform().equals(title.platform()));
default -> false;
};
if (!replaced) {
lines.add(new PlatformPlay(row.getSource(), row.getPlatform(), row.getAppid(), row.getPlaytimeMinutes(),
row.getLastPlayedAt()));
}
own.forEach(title -> lines.add(new PlatformPlay(GameSource.PSN, title.platform(), row.getAppid(),
title.playtimeMinutes(), title.lastPlayedAt())));
}
lines.sort(ORDER);
return lines;
}
public static int total(List<PlatformPlay> lines) {
return (int) Math.min(lines.stream().mapToLong(PlatformPlay::playtimeMinutes).sum(), Integer.MAX_VALUE);
}
}
@@ -6,4 +6,9 @@ package core_service.modules.library.domain;
* @param listed true when the trophy rows are stored; false until a sync has fetched them
*/
public record PsnTrophySetProgress(String id, String platform, String name, int total, int earned,
boolean platinumEarned, boolean listed) {}
boolean platinumEarned, boolean listed) {
public boolean perfect() {
return platinumEarned || earned >= total;
}
}
@@ -0,0 +1,12 @@
package core_service.modules.library.domain;
import java.time.Instant;
/**
* The play of one PlayStation title (V44).
*
* @param appid the game key the title is linked to; null when saving, the title is addressed by its key
* @param platform "PS5" or "PS4"
* @param lastPlayedAt may be null
*/
public record TitlePlay(String psnKey, Long appid, String platform, int playtimeMinutes, Instant lastPlayedAt) {}
@@ -91,7 +91,7 @@ public class UserGame {
@Column(name = "import_id")
private Long importId;
/** From PlayStation; null for Steam rows. */
/** From PlayStation or Steam; null when not known. */
@Column(name = "last_played_at")
private Instant lastPlayedAt;
@@ -335,6 +335,34 @@ public class GameLinkRepository {
return platforms;
}
/**
* @return per given entry key, the playtime of all its editions: a row's own minutes plus the minutes of the
* PlayStation titles on it, except that a PlayStation row with title play, or a manual row with a title of its platform,
* counts its titles only (a PlayStation row's own minutes are their sum). Capped at the int range. Same rule as {@code PlatformPlay#total}.
*/
public Map<Long, Integer> entryPlaytimes(long userId, Collection<Long> appids) {
Map<Long, Integer> playtimes = new HashMap<>();
if (appids.isEmpty()) {
return playtimes;
}
jdbc.query("""
select e.appid, least(sum(case when (m.source = 'PSN' and t.minutes is not null)
or (m.source = 'MANUAL' and exists (
select 1 from psn_title_play x where x.user_id = :user
and x.appid = m.appid and x.platform = m.platform)) then 0
else m.playtime_minutes end + coalesce(t.minutes, 0)),
2147483647) as minutes
from user_game_entry_member e
join listed_user_game m on m.user_id = e.user_id and m.appid = e.member_appid
left join (select appid, sum(playtime_minutes) as minutes from psn_title_play
where user_id = :user group by appid) t on t.appid = m.appid
where e.user_id = :user and e.appid in (:appids)
group by e.appid
""", new MapSqlParameterSource("user", userId).addValue("appids", appids),
rs -> { playtimes.put(rs.getLong("appid"), rs.getInt("minutes")); });
return playtimes;
}
private static MapSqlParameterSource keys(long appid, long otherAppid) {
return new MapSqlParameterSource("a", appid).addValue("b", otherAppid);
}
@@ -1,13 +1,17 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
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.Timestamp;
import java.time.Instant;
import java.util.Collection;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@@ -65,4 +69,40 @@ public class PsnTitleRepository {
psnKeys.stream().map(key -> new MapSqlParameterSource("user", userId).addValue("key", key))
.toArray(MapSqlParameterSource[]::new));
}
/**
* Stores the play of the LINKED and STEAM_OWNED titles among {@code plays}, addressed by {@code psnKey}; other
* rows are left alone.
*/
public void savePlays(long userId, List<TitlePlay> plays) {
if (plays.isEmpty()) {
return;
}
jdbc.batchUpdate("""
update user_psn_title set platform = :platform, playtime_minutes = :minutes, last_played_at = :played
where user_id = :user and psn_key = :key and state in ('LINKED', 'STEAM_OWNED')""",
plays.stream().map(play -> new MapSqlParameterSource("user", userId).addValue("key", play.psnKey())
.addValue("platform", play.platform()).addValue("minutes", play.playtimeMinutes())
.addValue("played", play.lastPlayedAt() == null ? null : Timestamp.from(play.lastPlayedAt())))
.toArray(MapSqlParameterSource[]::new));
}
/**
* The stored play of the titles linked to these keys (V44), a merged key resolved to its kept key.
*/
public List<TitlePlay> findPlays(long userId, Collection<Long> appids) {
if (appids.isEmpty()) {
return List.of();
}
return jdbc.query("""
select psn_key, appid, platform, playtime_minutes, last_played_at from psn_title_play
where user_id = :user and appid in (:appids) order by psn_key""",
new MapSqlParameterSource("user", userId).addValue("appids", appids),
(rs, row) -> new TitlePlay(rs.getString("psn_key"), rs.getLong("appid"), rs.getString("platform"),
rs.getInt("playtime_minutes"), instant(rs.getTimestamp("last_played_at"))));
}
private static Instant instant(Timestamp timestamp) {
return timestamp == null ? null : timestamp.toInstant();
}
}
@@ -51,8 +51,9 @@ public class QuestionRepository {
left join game_time_to_beat t on t.appid = ug.appid
left join game_mood m on m.appid = ug.appid
left join (select m.counted as appid, max(og.playtime_2weeks) as playtime_2weeks,
max(og.last_played_at) as last_played_at
greatest(max(og.last_played_at), max(p.last_played_at)) as last_played_at
from members m join listed_user_game og on og.user_id = :userId and og.appid = m.member
left join psn_title_play p on p.user_id = :userId and p.appid = m.member
group by m.counted) a on a.appid = ug.appid
left join (select m.counted as appid, max(d.day) as last_day
from members m join user_game_playtime_day d on d.user_id = :userId and d.appid = m.member
@@ -6,19 +6,23 @@ import core_service.modules.library.api.dto.CorrectionMarkDto;
import core_service.modules.library.api.dto.EditionDto;
import core_service.modules.library.api.dto.FinishedHintDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.AchievementProgress;
import core_service.modules.library.domain.ComingGame;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.GameAchievementSchema;
import core_service.modules.library.domain.GameAchievementSchemaMeta;
import core_service.modules.library.domain.FinishedHint;
import core_service.modules.library.domain.GameMoods;
import core_service.modules.library.domain.GameTimeToBeat;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.PsnTrophy;
import core_service.modules.library.domain.PsnTrophySetProgress;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameSpeedrun;
import core_service.modules.library.domain.SteamGame;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievement;
import core_service.modules.library.domain.UserGameAchievementMeta;
@@ -32,6 +36,7 @@ import core_service.modules.library.repo.GameMoodsRepository;
import core_service.modules.library.repo.GameTimeToBeatRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
import core_service.modules.library.repo.PsnTitleRepository;
import core_service.modules.library.repo.PsnTrophyRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.UserGameAchievementMetaRepository;
@@ -81,6 +86,7 @@ public class GameDetailService {
private final GameLinkRepository gameLinkRepository;
private final GameMergeRepository mergeRepository;
private final PsnTrophyRepository psnTrophyRepository;
private final PsnTitleRepository psnTitleRepository;
@Value("${app.library.achievement-ttl-hours:1}")
private long achievementTtlHours;
@@ -207,7 +213,7 @@ public class GameDetailService {
return psnTrophyRepository.findSets(userGame.getUserId(), userGame.getAppid()).stream()
.map(set -> new AchievementSetDto(set.id(), AchievementKind.PSN, set.platform(), set.name(), set.total(),
AchievementProgress.capped(set.earned(), set.total()),
set.platinumEarned() || set.earned() >= set.total(), true, set.listed()))
set.perfect(), true, set.listed()))
.toList();
}
@@ -241,6 +247,8 @@ public class GameDetailService {
.orElse(null);
Optional<ComingGame> coming = comingGameRepository.findById(userGame.getAppid());
boolean hasAchievements = total > 0 || !psnSets.isEmpty();
List<Edition> linked = gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid());
List<PlatformPlay> plays = platformPlays(userGame, linked);
List<AchievementSetDto> sets = new ArrayList<>();
if (total > 0) {
sets.add(new AchievementSetDto("steam", AchievementKind.STEAM, null, null, total, unlocked,
@@ -284,12 +292,76 @@ public class GameDetailService {
gameCorrectionRepository.findByAppidOrderByField(userGame.getAppid()).stream()
.map(c -> new CorrectionMarkDto(c.getField(), c.getCorrectedAt())).toList(),
gameLinkRepository.setOf(userGame.getAppid()).isPresent(),
gameLinkRepository.linkedEditions(userGame.getUserId(), userGame.getAppid()).stream()
linked.stream()
.map(e -> new EditionDto(e.appid(), e.name(), e.platform(), e.source(), e.status(), e.imageUrl()))
.toList(),
coming.isPresent(),
coming.map(ComingGame::getReleaseDate).orElse(null),
sets
sets,
plays.stream().map(line -> toDto(line, userGame, total, unlocked, statsVisible)).toList(),
PlatformPlay.total(plays)
);
}
// A hidden game shows only itself.
private List<PlatformPlay> platformPlays(UserGame userGame, List<Edition> linked) {
Long userId = userGame.getUserId();
List<UserGame> rows = new ArrayList<>(List.of(userGame));
if (userGame.getHiddenAt() == null) {
rows.addAll(userGameRepository.findAllById(
linked.stream().map(e -> new UserGameId(userId, e.appid())).toList()));
}
List<TitlePlay> titles = psnTitleRepository.findPlays(userId, rows.stream().map(UserGame::getAppid).toList());
return PlatformPlay.lines(rows, titles);
}
// total, unlocked and statsVisible are the current game's Steam values, already read.
private PlatformPlayDto toDto(PlatformPlay line, UserGame current, int total, int unlocked, boolean statsVisible) {
Long userId = current.getUserId();
Progress progress = switch (line.source()) {
case STEAM -> line.appid() == current.getAppid()
? steamProgress(total, unlocked, statsVisible) : steamProgress(userId, line.appid());
case PSN -> psnProgress(userId, line);
case MANUAL -> Progress.NONE;
};
return new PlatformPlayDto(line.source(), line.platform(), line.appid(), line.playtimeMinutes(),
line.lastPlayedAt(), progress.unlocked(), progress.total(), progress.perfect());
}
/**
* @param unlocked null when Steam hides the stats
*/
private record Progress(Integer unlocked, Integer total, boolean perfect) {
static final Progress NONE = new Progress(null, null, false);
}
private static Progress steamProgress(int total, int unlocked, boolean statsVisible) {
if (total <= 0) {
return Progress.NONE;
}
return new Progress(statsVisible ? unlocked : null, total, statsVisible && unlocked >= total);
}
private Progress steamProgress(Long userId, long appid) {
GameAchievementSchemaMeta schema = schemaMetaRepository.findById(appid).orElse(null);
if (schema == null || !schema.isHasSchema() || schema.getAchievementsTotal() <= 0) {
return Progress.NONE;
}
int total = schema.getAchievementsTotal();
UserGameAchievementMeta meta = userAchievementMetaRepository.findByUserIdAndAppid(userId, appid).orElse(null);
return steamProgress(total, meta == null ? 0 : AchievementProgress.capped(meta.getAchievementsUnlocked(), total),
meta == null || meta.isStatsVisible());
}
// The sets of the line's platform; every set of the key when the line has none.
private Progress psnProgress(Long userId, PlatformPlay line) {
List<PsnTrophySetProgress> sets = psnTrophyRepository.findSets(userId, line.appid()).stream()
.filter(set -> line.platform() == null || line.platform().equals(set.platform())).toList();
if (sets.isEmpty()) {
return Progress.NONE;
}
return new Progress(sets.stream().mapToInt(set -> AchievementProgress.capped(set.earned(), set.total())).sum(),
sets.stream().mapToInt(PsnTrophySetProgress::total).sum(),
sets.stream().allMatch(PsnTrophySetProgress::perfect));
}
}
@@ -288,6 +288,7 @@ public class LibraryQueryService {
.collect(Collectors.toMap(UserGameAchievementMeta::getAppid, m -> m));
Map<Long, Integer> editions = gameLinkRepository.editionCounts(games.getFirst().getUserId(), appids);
Map<Long, List<EntryPlatform>> platforms = gameLinkRepository.entryPlatforms(games.getFirst().getUserId(), appids);
Map<Long, Integer> totals = gameLinkRepository.entryPlaytimes(games.getFirst().getUserId(), appids);
Map<Long, ComingGame> coming = comingOf(appids);
Map<Long, AchievementProgress> trophies = AchievementProgress
.perGame(psnTrophyRepository.findProgress(games.getFirst().getUserId())).stream()
@@ -296,7 +297,7 @@ public class LibraryQueryService {
.map(ug -> toDto(ug, meta.get(ug.getAppid()), times.get(ug.getAppid()),
schemas.get(ug.getAppid()), unlocks.get(ug.getAppid()), editions.get(ug.getAppid()),
platforms.getOrDefault(ug.getAppid(), List.of()), coming.get(ug.getAppid()),
trophies.get(ug.getAppid())))
trophies.get(ug.getAppid()), totals.getOrDefault(ug.getAppid(), ug.getPlaytimeMinutes())))
.toList();
}
@@ -456,7 +457,7 @@ public class LibraryQueryService {
private static GameDto toDto(UserGame ug, SteamGame meta, GameTimeToBeat time,
GameAchievementSchemaMeta schema, UserGameAchievementMeta unlocks,
Integer editions, List<EntryPlatform> platforms, ComingGame coming,
AchievementProgress trophies) {
AchievementProgress trophies, int totalPlaytimeMinutes) {
Integer achievementsTotal = ug.getSource() == GameSource.STEAM && schema != null && schema.isHasSchema()
&& schema.getAchievementsTotal() > 0 ? schema.getAchievementsTotal() : null;
Integer achievementsUnlocked = achievementsTotal != null && unlocks != null && unlocks.isStatsVisible()
@@ -486,7 +487,8 @@ public class LibraryQueryService {
editions,
coming != null,
coming != null ? coming.getReleaseDate() : null,
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList());
platforms.stream().map(p -> new PlatformDto(p.source(), p.platform())).toList(),
totalPlaytimeMinutes);
}
// The tile shows what counts for the game: its best set, Steam's on a tie.
@@ -310,9 +310,12 @@ public class LibrarySyncWorker {
if (existing == null || existing.getRemovedAt() != null) {
changes.add(SyncChange.added(appid));
}
Instant lastPlayed = g.rtimeLastPlayed() != null && g.rtimeLastPlayed() > 0
? Instant.ofEpochSecond(g.rtimeLastPlayed()) : null;
if (existing == null) {
UserGame created = new UserGame(userId, appid, playtime, now, now);
created.setPlaytime2Weeks(playtime2Weeks);
created.setLastPlayedAt(lastPlayed);
userGameRepository.save(created);
} else {
existing.setPlaytimeMinutes(playtime);
@@ -321,6 +324,9 @@ public class LibrarySyncWorker {
existing.setRemovedAt(null);
existing.setSource(GameSource.STEAM);
existing.setPlatform(null);
if (lastPlayed != null) {
existing.setLastPlayedAt(lastPlayed);
}
}
if (playedOn == null || takesOverManualGame) {
return false;
@@ -6,6 +6,7 @@ import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -112,6 +113,8 @@ public class PsnSyncStep {
}
}
saveReview(userId, review);
psnTitleRepository.savePlays(userId, lines.stream().map(line -> new TitlePlay(line.psnKey(), null,
line.platform(), line.playtimeMinutes(), lastPlayed.get(line.psnKey()))).toList());
int added = changes.size();
changes.addAll(trophyStep.run(userId, found.get().accessToken(), titles));
log.info("PSN sync user={} added {} titles, {} wait for review, {} games gained trophies", userId, added,
@@ -21,6 +21,8 @@ public record SteamOwnedGamesResponse(
@JsonProperty("playtime_forever") Integer playtimeForever,
@JsonProperty("img_icon_url") String imgIconUrl,
@JsonProperty("has_community_visible_stats") Boolean hasCommunityVisibleStats,
@JsonProperty("playtime_2weeks") Integer playtime2Weeks
@JsonProperty("playtime_2weeks") Integer playtime2Weeks,
// unix seconds; 0 or absent when never played
@JsonProperty("rtime_last_played") Long rtimeLastPlayed
) {}
}
@@ -0,0 +1,27 @@
-- One entry per game across platforms (Gitea #34, slice 3). PlayStation play per title, so a title linked to a Steam
-- or manual row keeps its own platform, minutes and last played. Null until the next sync of the user.
ALTER TABLE user_psn_title
ADD COLUMN platform VARCHAR(8),
ADD COLUMN playtime_minutes INT,
ADD COLUMN last_played_at TIMESTAMPTZ;
-- The play of the user's linked titles per game key. A merge does not move user_psn_title.appid (readers map it
-- through game_alias), so the view resolves the alias. user_game columns are unchanged: no view rebuild.
CREATE VIEW psn_title_play AS
SELECT t.user_id, t.psn_key, COALESCE(a.to_appid, t.appid) AS appid, t.platform, t.playtime_minutes, t.last_played_at
FROM user_psn_title t
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE t.state IN ('LINKED', 'STEAM_OWNED') AND t.playtime_minutes IS NOT NULL;
-- The PlayStation row of an entry platform resolves the alias too (V43 joined user_psn_title.appid directly, so a
-- title on a merged key lost its icon). Same columns as V43: replace in place.
CREATE OR REPLACE VIEW user_game_entry_platform AS
SELECT m.user_id, m.appid, m.member_appid, ug.source, COALESCE(ug.platform, '') AS platform
FROM user_game_entry_member m
JOIN user_game ug ON ug.user_id = m.user_id AND ug.appid = m.member_appid
UNION
SELECT m.user_id, m.appid, m.member_appid, 'PSN', ''
FROM user_game_entry_member m
JOIN user_psn_title t ON t.user_id = m.user_id AND t.state IN ('LINKED', 'STEAM_OWNED')
LEFT JOIN game_alias a ON a.from_appid = t.appid
WHERE COALESCE(a.to_appid, t.appid) = m.member_appid;
@@ -80,7 +80,7 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
@BeforeEach
void seed() {
for (String table : List.of("game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
for (String table : List.of("user_psn_title", "game_link_request", "game_link_pair", "game_link", "game_edition", "admin_audit")) {
jdbc.update("delete from " + table);
}
userGameRepository.deleteAll();
@@ -293,6 +293,19 @@ class GameLinkFlowTest extends AbstractIntegrationTest {
.andExpect(jsonPath("$.content[0].platforms[1].platform").value("Switch"));
}
@Test
void games_ASetRowCarriesTheTotalPlaytimeOfAllItsPlatforms() throws Exception {
jdbc.update("update user_game set playtime_minutes = 500 where user_id = ? and appid = ?", USER_A, GAME);
jdbc.update("update user_game set source = 'PSN', platform = 'PS5', playtime_minutes = 300 where user_id = ? and appid = ?",
USER_A, GAME_2);
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, 'c9', 'LINKED', ?, 'PS5', 300)", USER_A, GAME_2);
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
mockMvc.perform(as(get("/api/library/games"), USER_A, Role.USER)).andExpect(status().isOk())
.andExpect(jsonPath("$.content[0].totalPlaytimeMinutes").value(800));
}
@Test
void games_AnOldClientsHideDuplicatesParamChangesNothing() throws Exception {
linkAs(Role.AUDITOR, GAME, GAME_2).andExpect(status().isNoContent());
@@ -217,7 +217,7 @@ class LibraryControllerAchievementsTest extends AbstractMockedAdaptersTest {
@Test
void librarySync_StartsTheAchievementSyncAfterCommit() {
when(steamApiService.getOwnedGames("76561198000000001"))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game(620, "Portal 2", 90, null, true, 0, null)));
when(steamApiService.getWishlist(anyString())).thenReturn(Optional.of(List.of()));
when(steamApiService.getSchemaForGame(anyLong())).thenReturn(Optional.empty());
@@ -60,7 +60,7 @@ class SyncChangesFlowTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game owned(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
private void sync(SyncTrigger trigger, SteamOwnedGamesResponse.Game... games) {
@@ -0,0 +1,102 @@
package core_service.modules.library.domain;
import org.junit.jupiter.api.Test;
import java.time.Instant;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
class PlatformPlayTest {
private static final long USER = 1L;
private static final Instant STEAM_PLAYED = Instant.parse("2026-10-01T10:00:00Z");
private static final Instant PSN_PLAYED = Instant.parse("2026-10-05T18:00:00Z");
private static UserGame row(long appid, GameSource source, String platform, int minutes, Instant lastPlayedAt) {
UserGame game = UserGame.manual(USER, appid, source, platform, GameStatus.PLAYING, minutes, Instant.EPOCH);
game.setLastPlayedAt(lastPlayedAt);
return game;
}
private static TitlePlay title(String key, long appid, String platform, int minutes) {
return new TitlePlay(key, appid, platform, minutes, PSN_PLAYED);
}
@Test
void lines_ASteamRowWithAnOwnedPlayStationTitleIsTwoLines_AndTheTotalIsTheirSum() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 120)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.STEAM, null, 100, 500, STEAM_PLAYED),
new PlatformPlay(GameSource.PSN, "PS5", 100, 120, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(620);
}
@Test
void lines_APlayStationRowWithTwoTitlesIsTheTitleLines_TheRowSumIsNotAddedOnTop() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)),
List.of(title("c4", 200, "PS4", 100), title("c5", 200, "PS5", 200)));
assertThat(lines).extracting(PlatformPlay::platform).containsExactly("PS5", "PS4");
assertThat(lines).extracting(PlatformPlay::playtimeMinutes).containsExactly(200, 100);
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_APlayStationRowWithoutStoredTitlePlayIsOneLineFromTheRow() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(200, GameSource.PSN, "PS5", 300, PSN_PLAYED)), List.of());
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", 200, 300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(300);
}
@Test
void lines_AManualRowWithALinkedTitleIsTheRowLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "Switch", 60, null)),
List.of(title("c1", -7, "PS4", 30)));
assertThat(lines).containsExactly(
new PlatformPlay(GameSource.PSN, "PS4", -7, 30, PSN_PLAYED),
new PlatformPlay(GameSource.MANUAL, "Switch", -7, 60, null));
}
@Test
void lines_ARowTheSteamSyncTookOverIsASteamLineAndTheTitleLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 40, STEAM_PLAYED)),
List.of(title("c1", 100, "PS5", 900)));
assertThat(lines).extracting(PlatformPlay::source).containsExactly(GameSource.STEAM, GameSource.PSN);
assertThat(PlatformPlay.total(lines)).isEqualTo(940);
}
@Test
void lines_OrderIsSteamThenPs5ThenPs4ThenManual_AndTitlesOfOtherRowsAreIgnored() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(
row(-7, GameSource.MANUAL, "Switch", 60, null),
row(200, GameSource.PSN, "PS4", 10, null),
row(100, GameSource.STEAM, null, 500, STEAM_PLAYED)),
List.of(title("c1", 200, "PS4", 10), title("c2", 100, "PS5", 20), title("c3", 999, "PS5", 7777)));
assertThat(lines).extracting(l -> l.source() + ":" + l.platform())
.containsExactly("STEAM:null", "PSN:PS5", "PSN:PS4", "MANUAL:Switch");
}
@Test
void total_IsCappedAtIntegerMax() {
List<PlatformPlay> lines = List.of(new PlatformPlay(GameSource.STEAM, null, 1, Integer.MAX_VALUE, null),
new PlatformPlay(GameSource.PSN, "PS5", 1, 5, null));
assertThat(PlatformPlay.total(lines)).isEqualTo(Integer.MAX_VALUE);
}
@Test
void lines_AManualRowWithALinkedTitleOfItsPlatformIsOneLine() {
List<PlatformPlay> lines = PlatformPlay.lines(List.of(row(-7, GameSource.MANUAL, "PS5", 1200, null)),
List.of(title("c1", -7, "PS5", 1300)));
assertThat(lines).containsExactly(new PlatformPlay(GameSource.PSN, "PS5", -7, 1300, PSN_PLAYED));
assertThat(PlatformPlay.total(lines)).isEqualTo(1300);
}
}
@@ -1,8 +1,13 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.Edition;
import core_service.modules.library.domain.EntryPlatform;
import core_service.modules.library.domain.GameSource;
import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.LinkPair;
import core_service.modules.library.domain.PlatformPlay;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -10,6 +15,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
@@ -259,6 +265,81 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
USER, appid, source, platform);
}
private void playtime(long appid, int minutes) {
jdbc.update("update user_game set playtime_minutes = ? where user_id = ? and appid = ?", minutes, USER, appid);
}
private void titlePlay(String key, long appid, String state, String platform, int minutes) {
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes) "
+ "values (?, ?, ?, ?, ?, ?)", USER, key, state, appid, platform, minutes);
}
private static UserGame row(long appid, GameSource source, String platform, int minutes) {
return UserGame.manual(USER, appid, source, platform, GameStatus.BACKLOG, minutes, Instant.EPOCH);
}
@Test
void entryPlaytimes_ASteamAndAnOwnedTitleSet_EqualsThePlatformPlayTotal() {
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", 100, "STEAM_OWNED", "PS5", 120);
var playtimes = repository.entryPlaytimes(USER, List.of(100L));
int expected = PlatformPlay.total(PlatformPlay.lines(List.of(row(100, GameSource.STEAM, null, 500)),
List.of(new TitlePlay("c1", 100L, "PS5", 120, null))));
assertThat(expected).isEqualTo(620);
assertThat(playtimes).containsOnly(Map.entry(100L, expected));
}
@Test
void entryPlaytimes_ASteamAndAPlayStationRowSet_EqualsThePlatformPlayTotal_TheRowSumIsNotAddedOnTop() {
repository.link(100, 200);
ownOn(100, "STEAM", null);
ownOn(200, "PSN", "PS5");
playtime(100, 500);
playtime(200, 300);
titlePlay("c4", 200, "LINKED", "PS4", 100);
titlePlay("c5", 200, "LINKED", "PS5", 200);
// A title without stored play counts nothing.
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid) values (?, 'c6', 'LINKED', 200)", USER);
var playtimes = repository.entryPlaytimes(USER, List.of(100L, 200L));
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(200, GameSource.PSN, "PS5", 300)),
List.of(new TitlePlay("c4", 200L, "PS4", 100, null), new TitlePlay("c5", 200L, "PS5", 200, null))));
assertThat(expected).isEqualTo(800);
assertThat(playtimes.values()).containsExactly(expected);
}
@Test
void entryPlaytimes_AManualRowWithATitleOfItsPlatform_EqualsThePlatformPlayTotal() {
repository.link(100, -7);
ownOn(100, "STEAM", null);
ownOn(-7, "MANUAL", "PS5");
playtime(100, 500);
playtime(-7, 1200);
titlePlay("c1", -7, "LINKED", "PS5", 1300);
titlePlay("c2", -7, "LINKED", "PS4", 60);
int expected = PlatformPlay.total(PlatformPlay.lines(
List.of(row(100, GameSource.STEAM, null, 500), row(-7, GameSource.MANUAL, "PS5", 1200)),
List.of(new TitlePlay("c1", -7L, "PS5", 1300, null), new TitlePlay("c2", -7L, "PS4", 60, null))));
assertThat(expected).isEqualTo(1860);
assertThat(repository.entryPlaytimes(USER, List.of(100L, -7L)).values()).containsExactly(expected);
}
@Test
void entryPlaytimes_APlayStationRowWithoutTitlePlay_IsItsOwnMinutes() {
ownOn(200, "PSN", "PS5");
playtime(200, 300);
assertThat(repository.entryPlaytimes(USER, List.of(200L))).containsOnly(Map.entry(200L, 300));
assertThat(repository.entryPlaytimes(USER, List.of())).isEmpty();
}
@Test
void entryPlatforms_SteamFirstThenPlayStationThenManual_AndAPlayStationTitleWithoutNameIsDropped() {
repository.link(100, 200);
@@ -367,4 +448,17 @@ class GameLinkRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.editionCounts(USER, List.of(100L, 200L, -7L))).isEqualTo(Map.of(100L, 2, 200L, 2));
assertThat(repository.editionCounts(OTHER_USER, List.of(100L, -7L))).isEqualTo(Map.of(100L, 2));
}
@Test
void entryPlatforms_ATitleOnAMergedKeyStillGivesThePlayStationIcon() {
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old key') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, 100) on conflict do nothing");
ownOn(100, "STEAM", null);
playtime(100, 500);
titlePlay("c1", -9, "STEAM_OWNED", "PS5", 120);
assertThat(repository.entryPlaytimes(USER, List.of(100L))).containsOnly(Map.entry(100L, 620));
assertThat(repository.entryPlatforms(USER, List.of(100L)).get(100L)).extracting(EntryPlatform::source)
.contains(GameSource.PSN);
}
}
@@ -1,16 +1,19 @@
package core_service.modules.library.repo;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.TitlePlay;
import core_service.shared.test.AbstractPersistenceTest;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Import;
import org.springframework.jdbc.core.JdbcTemplate;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.tuple;
@Import(PsnTitleRepository.class)
class PsnTitleRepositoryTest extends AbstractPersistenceTest {
@@ -72,4 +75,42 @@ class PsnTitleRepositoryTest extends AbstractPersistenceTest {
assertThat(repository.findAll(3L).get("c1").appid()).isEqualTo(-1L);
assertThat(repository.findAll(5L)).isEmpty();
}
@Test
void savePlays_UpdatesLinkedAndSteamOwnedTitles_NotADismissedOne() {
Instant played = Instant.parse("2026-10-05T18:00:00Z");
repository.link(6L, "c1", -1L);
repository.linkOwned(6L, "c2", -2L);
repository.dismiss(6L, List.of("c3"));
repository.savePlays(6L, List.of(new TitlePlay("c1", null, "PS5", 120, played),
new TitlePlay("c2", null, "PS4", 30, null), new TitlePlay("c3", null, "PS5", 999, played)));
assertThat(jdbc.queryForObject("select count(*) from user_psn_title where user_id = 6 and playtime_minutes is not null",
Integer.class)).isEqualTo(2);
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c1'",
Integer.class)).isEqualTo(120);
assertThat(jdbc.queryForObject("select platform from user_psn_title where user_id = 6 and psn_key = 'c2'",
String.class)).isEqualTo("PS4");
assertThat(jdbc.queryForObject("select playtime_minutes from user_psn_title where user_id = 6 and psn_key = 'c3'",
Integer.class)).isNull();
}
@Test
void findPlays_MapsAMergedKeyThroughTheAlias_AndLeavesOutTitlesWithoutPlay() {
game(7L, -1L, "STEAM");
jdbc.update("insert into steam_game (appid, name) values (-9, 'Old') on conflict do nothing");
jdbc.update("insert into game_alias (from_appid, to_appid) values (-9, -1) on conflict do nothing");
repository.link(7L, "c1", -9L);
repository.linkOwned(7L, "c2", -1L);
repository.link(7L, "c3", -1L);
repository.savePlays(7L, List.of(new TitlePlay("c1", null, "PS5", 120, null),
new TitlePlay("c2", null, "PS4", 30, null)));
List<TitlePlay> plays = repository.findPlays(7L, List.of(-1L));
assertThat(plays).extracting(TitlePlay::psnKey, TitlePlay::appid, TitlePlay::platform, TitlePlay::playtimeMinutes)
.containsExactlyInAnyOrder(tuple("c1", -1L, "PS5", 120), tuple("c2", -1L, "PS4", 30));
assertThat(repository.findPlays(7L, List.of())).isEmpty();
}
}
@@ -155,4 +155,13 @@ class QuestionRepositoryTest extends AbstractPersistenceTest {
jdbc.update("delete from user_game where appid = 50");
assertThat(jdbc.queryForObject("select count(*) from user_game_question", Integer.class)).isZero();
}
@Test
void facts_ThePlayStationLastPlayedOfATitleOnASteamRowCounts() {
jdbc.update("delete from user_psn_title");
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c1', 'STEAM_OWNED', 10, 'PS5', 600, '2026-10-08T20:00:00Z')", USER);
assertThat(only(10).lastPlayedAt()).isEqualTo(java.time.Instant.parse("2026-10-08T20:00:00Z"));
}
}
@@ -2,6 +2,7 @@ package core_service.modules.library.service;
import core_service.modules.library.api.dto.AchievementDto;
import core_service.modules.library.api.dto.GameDetailDto;
import core_service.modules.library.api.dto.PlatformPlayDto;
import core_service.modules.library.domain.AchievementKind;
import core_service.modules.library.domain.PsnEarned;
import core_service.modules.library.domain.PsnTrophy;
@@ -17,6 +18,7 @@ import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameAchievementMeta;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameAchievementSchemaMetaRepository;
import core_service.modules.library.repo.GameLinkRepository;
import core_service.modules.library.repo.SteamGameRepository;
import core_service.modules.library.repo.GameAchievementSchemaRepository;
import core_service.modules.library.repo.GameSpeedrunRepository;
@@ -34,6 +36,7 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.client.ResourceAccessException;
import java.sql.Timestamp;
import java.time.Duration;
import java.time.Instant;
import java.util.List;
@@ -86,6 +89,9 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
@Autowired
private PsnTrophyRepository psnTrophyRepository;
@Autowired
private GameLinkRepository gameLinkRepository;
@Autowired
private JdbcTemplate jdbc;
@@ -99,6 +105,8 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
gameSpeedrunRepository.deleteAll();
gameTimeToBeatRepository.deleteAll();
gameMoodsRepository.deleteAll();
jdbc.update("delete from user_psn_title");
jdbc.update("delete from game_link");
jdbc.update("delete from user_psn_trophy");
jdbc.update("delete from user_psn_trophy_set");
jdbc.update("delete from psn_trophy");
@@ -292,4 +300,44 @@ class GameDetailServiceTest extends AbstractMockedAdaptersTest {
assertEquals(4, detail.achievements().size());
assertEquals(1, detail.achievementsTotal());
}
@Test
void getGameDetail_ASteamAndAPlayStationEdition_HasOneLinePerPlatformWithItsOwnPlayAndProgress() {
Instant steamPlayed = Instant.parse("2026-10-01T10:00:00Z");
Instant psnPlayed = Instant.parse("2026-10-05T18:00:00Z");
UserGame steam = userGameRepository.findById(new UserGameId(USER_ID, 620L)).orElseThrow();
steam.setPlaytimeMinutes(500);
steam.setLastPlayedAt(steamPlayed);
userGameRepository.save(steam);
schemaMetaRepository.save(new GameAchievementSchemaMeta(620L, 3, true, Instant.now()));
for (String name : List.of("A", "B", "C")) {
schemaRepository.save(new GameAchievementSchema(620L, name, name, null, null, null, false));
}
userAchievementMetaRepository.save(new UserGameAchievementMeta(USER_ID, 620L, Instant.now(), true, 2, 60));
steamGameRepository.save(new SteamGame(621L, "Portal 2 (PS5)", null, Instant.now()));
userGameRepository.save(UserGame.manual(USER_ID, 621L, GameSource.PSN, "PS5", GameStatus.PLAYING, 300, Instant.now()));
gameLinkRepository.link(620L, 621L);
psnTrophyRepository.saveSet(USER_ID, 621L, new PsnTrophySet("NPWR9_00", "trophy2", "01.00", "Portal 2", null, "PS5",
2, 0, 0, 1, 2, false, Instant.now()));
jdbc.update("insert into user_psn_title (user_id, psn_key, state, appid, platform, playtime_minutes, last_played_at) "
+ "values (?, 'c9', 'LINKED', 621, 'PS5', 300, ?)", USER_ID, Timestamp.from(psnPlayed));
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(List.of(
new PlatformPlayDto(GameSource.STEAM, null, 620L, 500, steamPlayed, 2, 3, false),
new PlatformPlayDto(GameSource.PSN, "PS5", 621L, 300, psnPlayed, 2, 3, false)), detail.platforms());
assertEquals(800, detail.totalPlaytimeMinutes());
assertEquals(500, detail.playtimeMinutes());
}
@Test
void getGameDetail_AGameOfOnePlatform_HasOneLineAndItsOwnMinutesAsTotal() {
GameDetailDto detail = gameDetailService.getGameDetail(USER_ID, null, 620L).orElseThrow();
assertEquals(1, detail.platforms().size());
assertEquals(GameSource.STEAM, detail.platforms().getFirst().source());
assertEquals(60, detail.platforms().getFirst().playtimeMinutes());
assertEquals(60, detail.totalPlaytimeMinutes());
}
}
@@ -61,7 +61,7 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
void runSyncNow_TheFollowUpStoresTheSpeedrunRow() {
when(steamApiService.getWishlist(STEAM_ID)).thenReturn(Optional.empty());
when(steamApiService.getOwnedGames(STEAM_ID))
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0)));
.thenReturn(List.of(new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 0, "icon", true, 0, null)));
when(speedrunApiService.isEnabled()).thenReturn(true);
when(speedrunApiService.findGames("Half-Life 2")).thenReturn(Optional.of(List.of(
new SpeedrunApiService.Game("hl2", "Half-Life 2", 2004, "https://www.speedrun.com/hl2"))));
@@ -153,6 +153,6 @@ class LibrarySyncWorkerConcurrencyTest extends AbstractMockedAdaptersTest {
}
private static SteamOwnedGamesResponse.Game game(String iconUrl) {
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0);
return new SteamOwnedGamesResponse.Game((int) APPID, "Half-Life 2", 60, iconUrl, true, 0, null);
}
}
@@ -68,7 +68,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
private static final String OTHER_STEAM_ID = "76561198005679790";
private static SteamOwnedGamesResponse.Game game(int playtime, int playtime2Weeks) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks);
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", playtime, "icon", true, playtime2Weeks, null);
}
private static LocalDate today() {
@@ -297,6 +297,44 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
verify(playtimeDayRepository).addMinutes(USER_ID, 220L, today(), 120);
}
private static SteamOwnedGamesResponse.Game gameLastPlayed(Long rtimeLastPlayed) {
return new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 100, "icon", true, 0, rtimeLastPlayed);
}
@Test
void runSync_StoresSteamsLastPlayed() {
UserGame existing = owned(100);
steamReturns(gameLastPlayed(1_760_000_000L), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(Instant.ofEpochSecond(1_760_000_000L), existing.getLastPlayedAt());
}
@Test
void runSync_ZeroOrAbsentLastPlayed_KeepsTheOldValue() {
Instant old = Instant.parse("2026-10-05T18:00:00Z");
for (Long none : new Long[]{0L, null}) {
UserGame existing = owned(100);
existing.setLastPlayedAt(old);
steamReturns(gameLastPlayed(none), STEAM_ID);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
assertEquals(old, existing.getLastPlayedAt());
}
}
@Test
void runSync_NewSteamGame_GetsLastPlayedFromSteam() {
steamReturns(gameLastPlayed(1_760_000_000L), null);
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
verify(userGameRepository).save(argThat((UserGame created) ->
Instant.ofEpochSecond(1_760_000_000L).equals(created.getLastPlayedAt())));
}
@Test
void runSync_PlaytimeGrew_RecordsTheDifferenceForToday() {
owned(100);
@@ -618,7 +656,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
void runSync_GameWithoutName_GetsTheAppidAsName() {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null),
new SteamOwnedGamesResponse.Game(2000001, null, 12, null, null, null, null),
game(90, 0)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -633,7 +671,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
when(userSteamSyncRepository.tryClaimRun(eq(USER_ID), eq(STEAM_ID), any(), any())).thenReturn(1);
when(steamGameRepository.findAllById(List.of(2000001L))).thenReturn(List.of(stored));
when(steamApiService.getOwnedGames(STEAM_ID)).thenReturn(List.of(
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null)));
new SteamOwnedGamesResponse.Game(2000001, " ", 12, null, null, null, null)));
worker.runSync(USER_ID, STEAM_ID, SyncTrigger.AUTO);
@@ -673,7 +711,7 @@ class LibrarySyncWorkerTest extends AbstractUnitTest {
}
private static SteamOwnedGamesResponse.Game gameOf(int appid, int playtime) {
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime);
return new SteamOwnedGamesResponse.Game(appid, "Game " + appid, playtime, "icon", true, playtime, null);
}
@Test
@@ -7,6 +7,7 @@ import core_service.modules.library.domain.GameStatus;
import core_service.modules.library.domain.IgdbGame;
import core_service.modules.library.domain.PsnTitle;
import core_service.modules.library.domain.SyncChange;
import core_service.modules.library.domain.TitlePlay;
import core_service.modules.library.domain.UserGame;
import core_service.modules.library.domain.UserGameId;
import core_service.modules.library.repo.GameImportRepository;
@@ -195,6 +196,38 @@ class PsnSyncStepTest extends AbstractUnitTest {
verifyNoInteractions(manualGameService);
}
@Test
void run_SavesThePlayOfALinkedAndASteamOwnedTitle_AndGivesNoPlaytimeToTheSteamRow() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ofMinutes(30), 8L, null);
fetches(ghost(), other);
when(psnTitleRepository.findAll(USER)).thenReturn(Map.of(
"c7", new PsnTitle("c7", PsnTitle.State.LINKED, -1L),
"c8", new PsnTitle("c8", PsnTitle.State.STEAM_OWNED, 100L)));
PsnSyncStep.Prepared prepared = step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactlyInAnyOrder(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED),
new TitlePlay("c8", null, "PS5", 30, null));
assertThat(prepared.playtimes()).extracting(PsnSyncStep.Playtime::appid).containsExactly(-1L);
}
@Test
void run_ATitleLinkedInThisRunIsAlsoInTheSavedPlay() {
fetches(ghost());
IgdbGame game = igdb(1, "Ghost of Yotei", "PS5");
when(manualGameService.findGames("Ghost of Yotei")).thenReturn(Optional.of(List.of(game)));
when(manualGameService.addGame(eq(USER), eq(game), eq("PS5"), eq(GameStatus.PLAYING), eq(120),
eq(GameSource.PSN), isNull())).thenReturn(new ManualGameService.AddResult.Added(-1L));
step.run(USER);
ArgumentCaptor<List<TitlePlay>> plays = ArgumentCaptor.captor();
verify(psnTitleRepository).savePlays(eq(USER), plays.capture());
assertThat(plays.getValue()).containsExactly(new TitlePlay("c7", null, "PS5", 120, LAST_PLAYED));
}
@Test
void run_DismissedTitleAndTitleOfAnOpenImport_AreNotSearchedNorOffered() {
PsnApiService.Title other = new PsnApiService.Title("PPSA2_00", "Other", "ps5_native_game", Duration.ZERO, 8L, null);
@@ -109,7 +109,7 @@ class SteamApiServiceCacheTest extends AbstractIntegrationTest {
@Test
void getOwnedGames_TransientSteamError_IsTriedAgain() {
SteamOwnedGamesResponse games = new SteamOwnedGamesResponse(new SteamOwnedGamesResponse.OwnedGamesResponse(
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0))));
List.of(new SteamOwnedGamesResponse.Game(220, "Half-Life 2", 60, null, true, 0, null))));
when(steamRestClient.get().uri(any(Function.class)).retrieve().body(SteamOwnedGamesResponse.class))
.thenAnswer(invocation -> {
if (steamCalls.incrementAndGet() == 1) {
@@ -88,7 +88,8 @@ class SteamApiServiceTest {
"name": "Counter-Strike",
"playtime_forever": 32,
"img_icon_url": "icon_url",
"has_community_visible_stats": true
"has_community_visible_stats": true,
"rtime_last_played": 1760000000
}
]
}
@@ -104,6 +105,7 @@ class SteamApiServiceTest {
assertThat(result).hasSize(1);
assertThat(result.getFirst().name()).isEqualTo("Counter-Strike");
assertThat(result.getFirst().rtimeLastPlayed()).isEqualTo(1_760_000_000L);
}
@Test