Skip to content

Commit

Permalink
feat: add function to retrieve recently played games (#134)
Browse files Browse the repository at this point in the history
* feat: add function to fetch recently played games

* chore: address linter issues

* chore: remove unnecessary hash computation

* Update src/user/getUserGameList.test.ts

Co-authored-by: Wes Copeland <wlcopeland1@gmail.com>

* Update src/user/getUserGameList.test.ts

Co-authored-by: Wes Copeland <wlcopeland1@gmail.com>

* Update src/user/getUserGameList.ts

Co-authored-by: Wes Copeland <wlcopeland1@gmail.com>

* chore: resolve merge conflict

* chore: separate gpl op hashes into their own file

* chore: revert yarn.lock

* chore: address yarn verify issues

* chore: rename graphql function

* chore: rename function files
docs: add docs for new recently played games funcs

* chore: run lint/fix/verify

* chore: move comments related to graphql endpoints to appropriate files
chore: refactor getRecentlyPlayedGames test
doc: update readme with getRecentlyPlayedGames

* chore: address linter issues

---------

Co-authored-by: Wes Copeland <wlcopeland1@gmail.com>
  • Loading branch information
evanshortiss and wescopeland authored Mar 14, 2023
1 parent 840b131 commit 9846d3a
Show file tree
Hide file tree
Showing 12 changed files with 354 additions and 4 deletions.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ Click the function names to open their complete docs on the docs site.
- [`getUserTrophiesEarnedForTitle()`](https://psn-api.achievements.app/api-docs/user-trophies#getusertrophiesearnedfortitle) - Retrieve the earned status of trophies for a user from either a single or all trophy groups in a title.
- [`getUserTrophyGroupEarningsForTitle()`](https://psn-api.achievements.app/api-docs/user-trophies#getusertrophygroupearningsfortitle) - Get a summary of trophies earned for a user broken down by trophy group within a title.
- [`getUserTrophyProfileSummary()`](https://psn-api.achievements.app/api-docs/user-trophies#getusertrophyprofilesummary) - Retrieve an overall summary of the number of trophies earned for a user broken down by type.
- [`getRecentlyPlayedGames()`](https://psn-api.achievements.app/api-docs/users#getrecentlyplayedgames) - Retrieve a list of recently played games for the user associated with the access token provided to this function.

## Examples

Expand Down
2 changes: 2 additions & 0 deletions src/graphql/GRAPHQL_BASE_URL.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export const GRAPHQL_BASE_URL =
"https://web.np.playstation.com/api/graphql/v1/op";
136 changes: 136 additions & 0 deletions src/graphql/getRecentlyPlayedGames.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import { rest } from "msw";
import { setupServer } from "msw/node";

import type {
AuthorizationPayload,
RecentlyPlayedGamesResponse
} from "../models";
import { getRecentlyPlayedGames } from "./getRecentlyPlayedGames";
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";

const server = setupServer();
const accessToken = "mockAccessToken";

describe("Function: getRecentlyPlayedGames", () => {
// MSW Setup
beforeAll(() =>
server.listen({
onUnhandledRequest: "error"
})
);
afterEach(() => server.resetHandlers());
afterAll(() => server.close());

it("is defined #sanity", () => {
// ASSERT
expect(getRecentlyPlayedGames).toBeDefined();
});

it("retrieves games the user has played recently", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockResponse: RecentlyPlayedGamesResponse = {
data: {
gameLibraryTitlesRetrieve: {
__typename: "GameList",
games: [
{
__typename: "GameLibraryTitle",
conceptId: "203715",
entitlementId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
image: {
__typename: "Media",
url: "https://image.api.playstation.com/gs2-sec/appkgo/prod/CUSA01433_00/7/i_5c5e430a49994f22df5fd81f446ead7b6ae45027af490b415fe4e744a9918e4c/i/icon0.png"
},
isActive: true,
lastPlayedDateTime: "2023-03-10T01:01:01.390000Z",
name: "Rocket League®",
platform: "PS4",
productId: "EP2002-CUSA01433_00-ROCKETLEAGUEEU01",
subscriptionService: "NONE",
titleId: "CUSA01433_00"
},
{
__typename: "GameLibraryTitle",
conceptId: "10004142",
entitlementId: null,
image: {
__typename: "Media",
url: "https://image.api.playstation.com/vulcan/ap/rnd/202208/2505/DE9sevLlnfHm7vLrRwDFEZpO.png"
},
isActive: null,
lastPlayedDateTime: "2023-01-19T01:01:01.900000Z",
name: "CRISIS CORE –FINAL FANTASY VII– REUNION PS4 & PS5",
platform: "PS5",
productId: null,
subscriptionService: "NONE",
titleId: "PPSA07809_00"
}
]
}
}
};

let headers!: Record<string, string>;
let searchParams!: URLSearchParams;

server.use(
rest.get(GRAPHQL_BASE_URL, (_, res, ctx) => {
headers = _.headers.raw();
searchParams = _.url.searchParams;

return res(ctx.json(mockResponse));
})
);

// ACT
const response = await getRecentlyPlayedGames(mockAuthorization, {
categories: ["ps4_game", "ps5_native_game"],
limit: 2
});

// ASSERT
expect(response).toEqual(mockResponse);
expect(headers["authorization"]).toEqual(`Bearer ${accessToken}`);
expect(searchParams.get("operationName")).toEqual("getUserGameList");
expect(searchParams.get("variables")).toEqual(
JSON.stringify({ limit: 2, categories: "ps4_game,ps5_native_game" })
);
expect(searchParams.get("extensions")).toEqual(
JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash:
"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"
}
})
);
});

it("throws an error if we receive a malformed response", async () => {
// ARRANGE
const mockAuthorization: AuthorizationPayload = {
accessToken
};

const mockResponse = {
// This response occurs if the query/hash is not what the server expected
message:
"Query 4e8add9915e3cb6870d778cff38e7f81899066f5603ced4c87d6d7c0abc99941 not whitelisted"
};

server.use(
rest.get(GRAPHQL_BASE_URL, (_, res, ctx) => {
return res(ctx.json(mockResponse));
})
);

// ASSERT
await expect(getRecentlyPlayedGames(mockAuthorization)).rejects.toThrow(
"Query 4e8add9915e3cb6870d778cff38e7f81899066f5603ced4c87d6d7c0abc99941 not whitelisted"
);
});
});
60 changes: 60 additions & 0 deletions src/graphql/getRecentlyPlayedGames.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type {
AllCallOptions,
AuthorizationPayload,
RecentlyPlayedGamesResponse
} from "../models";
import { call } from "../utils/call";
import { GRAPHQL_BASE_URL } from "./GRAPHQL_BASE_URL";
import { getUserGameListHash } from "./operationHashes";

type GetRecentlyPlayedGamesOptionsCategories = "ps4_game" | "ps5_native_game";
type GetRecentlyPlayedGamesOptions = Pick<AllCallOptions, "limit"> & {
categories: GetRecentlyPlayedGamesOptionsCategories[];
};

/**
* A call to this function will retrieve recently played games for the user associated
* with the npsso token provided to this module during initialisation.
*
* This is useful if you want recent activity that isn't tied to trophy progress.
*
* @param authorization An object containing your access token, typically retrieved with `exchangeCodeForAccessToken()`.
*/
export const getRecentlyPlayedGames = async (
authorization: AuthorizationPayload,
options: Partial<GetRecentlyPlayedGamesOptions> = {}
): Promise<RecentlyPlayedGamesResponse> => {
const { limit = 50, categories = ["ps4_game", "ps5_native_game"] } = options;

const url = new URL(GRAPHQL_BASE_URL);

url.searchParams.set("operationName", "getUserGameList");
url.searchParams.set(
"variables",
JSON.stringify({
limit,
categories: categories.join(",")
})
);
url.searchParams.set(
"extensions",
JSON.stringify({
persistedQuery: {
version: 1,
sha256Hash: getUserGameListHash
}
})
);

const response = await call<RecentlyPlayedGamesResponse>(
{ url: url.toString() },
authorization
);

// The GraphQL queries can return non-truthy values.
if (!response.data || !response.data.gameLibraryTitlesRetrieve) {
throw new Error(JSON.stringify(response));
}

return response;
};
1 change: 1 addition & 0 deletions src/graphql/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./getRecentlyPlayedGames";
21 changes: 21 additions & 0 deletions src/graphql/operationHashes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
/**
* GraphQL endpoints work differently to others in the codebase.
*
* The hashes in this file are reverse engineered from app-<hash>.js file loaded by the page
* at https://library.playstation.com/recently-played. Following the code in that file leads
* to some Apollo GraphQL code related to persisted queries. This means the request needs to
* contain a SHA256 hash of the GraphQL query being executed. Searching for PersistedQueryLink
* and createPersistedQueryLink_hashes, and an AST function in Sony's JS source and debugging
* will surface the exact GraphQL query that's passed to the hash function on the page.
*
* Thankfully it's easier to figure out future endpoints and hashes by:
*
* 1. Visiting a page, e.g https://library.playstation.com/recently-played
* 2. Using DevTools to find requests to https://web.np.playstation.com/api/graphql/v1/op
* 3. Decoding the URL parameters to find the correct SHA256 hash and some of the supported parameters
*/

// Hash is computed from the following query (without surrounding quotes):
// "query getUserGameList($categories: String, $limit: Int, $orderBy: String, $subscriptionService: SubscriptionService) {\n gameLibraryTitlesRetrieve(categories: $categories, limit: $limit, orderBy: $orderBy, subscriptionService: $subscriptionService) {\n __typename\n games {\n __typename\n conceptId\n entitlementId\n image {\n __typename\n url\n }\n isActive\n lastPlayedDateTime\n name\n platform\n productId\n subscriptionService\n titleId\n }\n }\n}\n"
export const getUserGameListHash =
"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae";
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from "./authenticate";
export * from "./graphql";
export * from "./models";
export * from "./search";
export * from "./trophy";
Expand Down
1 change: 1 addition & 0 deletions src/models/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export * from "./get-user-friends-account-ids-response.model";
export * from "./profile-from-account-id-response.model";
export * from "./profile-from-user-name-response.model";
export * from "./rarest-thin-trophy.model";
export * from "./recently-played-games-response.model";
export * from "./social-account-result.model";
export * from "./title-platform.model";
export * from "./title-thin-trophy.model";
Expand Down
72 changes: 72 additions & 0 deletions src/models/recently-played-games-response.model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { TitlePlatform } from "./title-platform.model";

export interface RecentlyPlayedGame {
/** GrahpQL object type/schema */
__typename: "GameLibraryTitle";

/** Contains a url to a game icon file. */
image: {
__typename: "Media";
url: string;
};

/**
* Unclear what this represents, but if this is set to true then
* the productId property is not null.
*/
isActive: boolean | null;

/**
* An ISO date representing the last play date and time
* of the given title, e.g 2023-03-10T01:01:01.390000Z
*/
lastPlayedDateTime: string;

/** The name of the game */
name: string;

/**
* The platform this game was last played on. This can be reported as
* "UNKNOWN". It appears that "UNKNOWN" is shown in certain scenarios
* when a user that isn't associated with the access token is sharing
* the same console as the user that is identified by the access token.
*/
platform: TitlePlatform | "UNKNOWN";

/**
* ID of the product. Used in the PlayStation Store URL. Appears to only be
* set for certain PS4 games along with the entitlementId.
*/
productId: string | null;

/**
* Similar to productId. Used in URLs for the PlayStation Store, i.e
* store.playstation.com/en-ae/product/:entitlementId. Appears to only
* be set for certain entries on the PS4 platform along with productId.
*/
entitlementId: string | null;

/**
* ID of the product. Forms part of a PlayStation Store URL, e.g
* https://store.playstation.com/en-us/product/UP9000-$titleId-RATCHETCLANKRIFT
*/
titleId: string;

/**
* An ID for titles on the PlayStation store. It's used in the
* URL, i.e store.playstation.com/ko-kr/concept/:conceptId
*/
conceptId: string;

/** Unsure what data this can hold. Perhaps it's PS Now related? */
subscriptionService: "NONE" | string;
}

export interface RecentlyPlayedGamesResponse {
data: {
gameLibraryTitlesRetrieve: {
__typename: "GameList";
games: RecentlyPlayedGame[];
};
};
}
9 changes: 5 additions & 4 deletions src/trophy/user/getUserTitles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,11 @@ type GetUserTitlesOptions = Pick<

/**
* A call to this function will retrieve a summarized list of titles played
* by a user. The maximum amount that can be returned by a single call is
* 800 (assuming a `limit` option of 800 is set). If the user has more titles than the
* given `limit` option, subsequent calls of this funciton must be made to
* fetch the complete list by paging via the `offset` option.
* by a user, ordered by recent trophy unlocks. The maximum amount that can
* be returned by a single call is 800 (assuming a `limit` option of 800 is set).
* If the user has more titles than the given `limit` option, subsequent calls
* of this funciton must be made to fetch the complete list by paging via the
* `offset` option.
*
* The numeric `accountId` can be that of any PSN account for which the authenticating
* account has permissions to view the trophy list. When querying the titles
Expand Down
12 changes: 12 additions & 0 deletions website/docs/api-docs/data-models/recently-played-game.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Recently Played Game

| Name | Type | Description |
| :------------------- | :--------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `name` | `string` | The name of the game. |
| `lastPlayedDateTime` | `string` | An ISO date representing the last play date and time of the given title, e.g 2023-03-10T01:01:01.390000Z. |
| `conceptId` | `string` | An ID for titles on the PlayStation store. It's used in the URL, like so `store.playstation.com/en-is/concept/:conceptId`. |
| `titleId` | `string` | ID of the product. Forms part of a PlayStation Store URL, e.g `https://store.playstation.com/en-us/product/UP9000-$titleId-RATCHETCLANKRIFT` |
| `platform` | `TitlePlatform`\|`"UNKNOWN"` | The platform this game was last played on. This can be reported as "UNKNOWN". It appears that "UNKNOWN" is shown in certain scenarios, such as when a user that **isn't** associated with the access token is sharing the same console as the user that **is** identified by the access token. |
| `entitlementId` | `string`\|`null` | Similar to productId. Used in URLs for the PlayStation Store, like so `store.playstation.com/en-ae/product/:entitlementId`. Appears to only be set for certain entries on the PS4 platform along with `productId`. |
| `productId` | `string`\|`null` | ID of the product. Used in the PlayStation Store URL. Appears to only be set for certain PS4 games along with the entitlementId. |
| `image.url` | `string` | Contains a URL to a game icon file. |
42 changes: 42 additions & 0 deletions website/docs/api-docs/users.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,45 @@ These are the possible values that can be in the `options` object (the third par
### Source

[user/getUserFriendsAccountIds.ts](https://github.com/achievements-app/psn-api/blob/main/src/user/getUserFriendsAccountIds.ts)

---

## getRecentlyPlayedGames

A call to this function will retrieve a list of recently played games for the user associated with the `accessToken` in the provided [AuthorizationPayload](/api-docs/data-models/authorization-payload).

### Examples

```ts
import { getRecentlyPlayedGames } from "psn-api";

const recentlyPlayedGames = await getRecentlyPlayedGames(authorization, {
limit: 10,
categories: ["ps4_game", "ps5_native_game"]
});
```

### Returns

| Name | Type | Description |
| :------------------------------------- | :----------------------------------------------------------------- | :----------------------------- |
| `data.gameLibraryTitlesRetrieve.games` | [RecentlyPlayedGame](/api-docs/data-models/recently-played-game)[] | List of recently played games. |

### Parameters

| Name | Type | Description |
| :-------------- | :-------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------- |
| `authorization` | [`AuthorizationPayload`](/api-docs/data-models/authorization-payload) | An object that must contain an `accessToken`. See [this page](/authentication/authenticating-manually) for how to get one. |

### Options

These are the possible values that can be in the `options` object (the second parameter of the function).

| Name | Type | Description |
| :----------- | :--------- | :------------------------------------------------------------------------------------------ |
| `limit` | `number` | Limit the number of games returned. Defaults to 50. |
| `categories` | `string[]` | Limit the categories of games returned. Valid entries are `ps4_game` and `ps5_native_game`. |

### Source

[graphql/getRecentlyPlayedGames.ts](https://github.com/achievements-app/psn-api/blob/main/src/graphql/getRecentlyPlayedGames.ts)

1 comment on commit 9846d3a

@vercel
Copy link

@vercel vercel bot commented on 9846d3a Mar 14, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.