From 9846d3a9642343703e4d2e62f916a79d6ddcda2c Mon Sep 17 00:00:00 2001 From: Evan Shortiss Date: Tue, 14 Mar 2023 05:41:25 -0700 Subject: [PATCH] feat: add function to retrieve recently played games (#134) * 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 * Update src/user/getUserGameList.test.ts Co-authored-by: Wes Copeland * Update src/user/getUserGameList.ts Co-authored-by: Wes Copeland * 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 --- README.md | 1 + src/graphql/GRAPHQL_BASE_URL.ts | 2 + src/graphql/getRecentlyPlayedGames.test.ts | 136 ++++++++++++++++++ src/graphql/getRecentlyPlayedGames.ts | 60 ++++++++ src/graphql/index.ts | 1 + src/graphql/operationHashes.ts | 21 +++ src/index.ts | 1 + src/models/index.ts | 1 + .../recently-played-games-response.model.ts | 72 ++++++++++ src/trophy/user/getUserTitles.ts | 9 +- .../data-models/recently-played-game.md | 12 ++ website/docs/api-docs/users.md | 42 ++++++ 12 files changed, 354 insertions(+), 4 deletions(-) create mode 100644 src/graphql/GRAPHQL_BASE_URL.ts create mode 100644 src/graphql/getRecentlyPlayedGames.test.ts create mode 100644 src/graphql/getRecentlyPlayedGames.ts create mode 100644 src/graphql/index.ts create mode 100644 src/graphql/operationHashes.ts create mode 100644 src/models/recently-played-games-response.model.ts create mode 100644 website/docs/api-docs/data-models/recently-played-game.md diff --git a/README.md b/README.md index 14479b4..3ff7142 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/graphql/GRAPHQL_BASE_URL.ts b/src/graphql/GRAPHQL_BASE_URL.ts new file mode 100644 index 0000000..a1f72a8 --- /dev/null +++ b/src/graphql/GRAPHQL_BASE_URL.ts @@ -0,0 +1,2 @@ +export const GRAPHQL_BASE_URL = + "https://web.np.playstation.com/api/graphql/v1/op"; diff --git a/src/graphql/getRecentlyPlayedGames.test.ts b/src/graphql/getRecentlyPlayedGames.test.ts new file mode 100644 index 0000000..38e8179 --- /dev/null +++ b/src/graphql/getRecentlyPlayedGames.test.ts @@ -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; + 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" + ); + }); +}); diff --git a/src/graphql/getRecentlyPlayedGames.ts b/src/graphql/getRecentlyPlayedGames.ts new file mode 100644 index 0000000..65dffb5 --- /dev/null +++ b/src/graphql/getRecentlyPlayedGames.ts @@ -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 & { + 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 = {} +): Promise => { + 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( + { 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; +}; diff --git a/src/graphql/index.ts b/src/graphql/index.ts new file mode 100644 index 0000000..da84229 --- /dev/null +++ b/src/graphql/index.ts @@ -0,0 +1 @@ +export * from "./getRecentlyPlayedGames"; diff --git a/src/graphql/operationHashes.ts b/src/graphql/operationHashes.ts new file mode 100644 index 0000000..eade307 --- /dev/null +++ b/src/graphql/operationHashes.ts @@ -0,0 +1,21 @@ +/** + * GraphQL endpoints work differently to others in the codebase. + * + * The hashes in this file are reverse engineered from app-.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"; diff --git a/src/index.ts b/src/index.ts index 4e7f306..bad1a1c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ export * from "./authenticate"; +export * from "./graphql"; export * from "./models"; export * from "./search"; export * from "./trophy"; diff --git a/src/models/index.ts b/src/models/index.ts index 87d759c..107dde4 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -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"; diff --git a/src/models/recently-played-games-response.model.ts b/src/models/recently-played-games-response.model.ts new file mode 100644 index 0000000..022a431 --- /dev/null +++ b/src/models/recently-played-games-response.model.ts @@ -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[]; + }; + }; +} diff --git a/src/trophy/user/getUserTitles.ts b/src/trophy/user/getUserTitles.ts index 688c806..95b7ad8 100644 --- a/src/trophy/user/getUserTitles.ts +++ b/src/trophy/user/getUserTitles.ts @@ -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 diff --git a/website/docs/api-docs/data-models/recently-played-game.md b/website/docs/api-docs/data-models/recently-played-game.md new file mode 100644 index 0000000..c25e9cb --- /dev/null +++ b/website/docs/api-docs/data-models/recently-played-game.md @@ -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. | diff --git a/website/docs/api-docs/users.md b/website/docs/api-docs/users.md index 6fcb3fa..72d6eb4 100644 --- a/website/docs/api-docs/users.md +++ b/website/docs/api-docs/users.md @@ -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)