Skip to content

Commit

Permalink
version 0.35
Browse files Browse the repository at this point in the history
  • Loading branch information
FriendsOfGalaxy committed Aug 27, 2021
1 parent 83373b0 commit 12b698f
Show file tree
Hide file tree
Showing 6 changed files with 201 additions and 195 deletions.
4 changes: 0 additions & 4 deletions src/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@
DEFAULT_TIMEOUT = 30


def paginate_url(url, limit, offset=0):
return url + "&limit={limit}&offset={offset}".format(limit=limit, offset=offset)


class CookieJar(aiohttp.CookieJar):
def __init__(self):
super().__init__()
Expand Down
82 changes: 22 additions & 60 deletions src/psn_client.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,23 @@
import asyncio
import logging
import math
import re
from datetime import datetime, timezone, timedelta
from functools import partial
from typing import List, NewType, Optional
from typing import List, NewType

from galaxy.api.errors import UnknownBackendResponse
from galaxy.api.types import SubscriptionGame

from http_client import paginate_url
from parsers import PSNGamesParser


GAME_LIST_URL = "https://web.np.playstation.com/api/graphql/v1/op" \
"?operationName=getPurchasedGameList" \
'&variables={"isActive":true,"platform":["ps3","ps4","ps5"],"subscriptionService":"NONE"}' \
'&extensions={"persistedQuery":{"version":1,"sha256Hash":"2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2"}}'
'&variables={{"isActive":true,"platform":["ps3","ps4","ps5"],"start":{start},"size":{size},"subscriptionService":"NONE"}}' \
'&extensions={{"persistedQuery":{{"version":1,"sha256Hash":"2c045408b0a4d0264bb5a3edfed4efd49fb4749cf8d216be9043768adff905e2"}}}}'

PLAYED_GAME_LIST_URL = "https://web.np.playstation.com/api/graphql/v1/op" \
"?operationName=getUserGameList" \
'&variables={"limit":50,"categories":"ps3_game,ps4_game,ps5_native_game"}' \
'&extensions={"persistedQuery":{"version":1,"sha256Hash":"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"}}'
'&variables={{"categories":"ps3_game,ps4_game,ps5_native_game","limit":{size}}}' \
'&extensions={{"persistedQuery":{{"version":1,"sha256Hash":"e780a6d8b921ef0c59ec01ea5c5255671272ca0d819edb61320914cf7a78b3ae"}}}}'

USER_INFO_URL = "https://web.np.playstation.com/api/graphql/v1/op" \
"?operationName=getProfileOracle" \
Expand All @@ -31,40 +28,10 @@

DEFAULT_LIMIT = 100

TitleId = NewType("TitleId", str)
UnixTimestamp = NewType("UnixTimestamp", int)


def parse_timestamp(earned_date) -> UnixTimestamp:
date_format = "%Y-%m-%dT%H:%M:%S.%fZ" if '.' in earned_date else "%Y-%m-%dT%H:%M:%SZ"
dt = datetime.strptime(earned_date, date_format)
dt = datetime.combine(dt.date(), dt.time(), timezone.utc)
return UnixTimestamp(int(dt.timestamp()))
# 100 is a maximum possible value to provide
PLAYED_GAME_LIST_URL = PLAYED_GAME_LIST_URL.format(size=DEFAULT_LIMIT)


def parse_play_duration(duration: Optional[str]) -> int:
"""Returns time of played game in minutes from PSN API format `PT{HOURS}H{MINUTES}M{SECONDS}S`. Example: `PT2H33M3S`"""
if not duration:
raise UnknownBackendResponse(f'nullable playtime duration: {type(duration)}')
try:
result = re.match(
r'(?:PT)?'
r'(?:(?P<hours>\d*)H)?'
r'(?:(?P<minutes>\d*)M)?'
r'(?:(?P<seconds>\d*)S)?$',
duration
)
mapped_result = {k: float(v) for k, v in result.groupdict(0).items()}
time = timedelta(**mapped_result)
except (ValueError, AttributeError, TypeError):
raise UnknownBackendResponse(f'Unmatchable gametime: {duration}')

total_minutes = math.ceil(time.seconds / 60 + time.days * 24 * 60)
return total_minutes


def date_today():
return datetime.today()
UnixTimestamp = NewType("UnixTimestamp", int)


class PSNClient:
Expand All @@ -80,22 +47,23 @@ async def fetch_paginated_data(
self,
parser,
url,
operation_name,
counter_name,
limit=DEFAULT_LIMIT,
*args,
**kwargs
):
response = await self._http_client.get(paginate_url(url=url, limit=limit), *args, **kwargs)
response = await self._http_client.get(url.format(size=limit, start=0), *args, **kwargs)
if not response:
return []

try:
total = int(response.get(counter_name, 0))
except ValueError:
raise UnknownBackendResponse()
total = int(response["data"][operation_name]["pageInfo"].get(counter_name, 0))
except (ValueError, KeyError, TypeError) as e:
raise UnknownBackendResponse(e)

responses = [response] + await asyncio.gather(*[
self._http_client.get(paginate_url(url=url, limit=limit, offset=offset), *args, **kwargs)
self._http_client.get(url.format(size=limit, start=offset), *args, **kwargs)
for offset in range(limit, total, limit)
])

Expand All @@ -122,10 +90,7 @@ def user_info_parser(response):
response["data"]["oracleUserProfileRetrieve"]["onlineId"]
except (KeyError, TypeError) as e:
raise UnknownBackendResponse(e)
return await self.fetch_data(
user_info_parser,
USER_INFO_URL,
)
return await self.fetch_data(user_info_parser, USER_INFO_URL)

async def get_psplus_status(self) -> bool:

Expand All @@ -138,10 +103,10 @@ def user_subscription_parser(response):
except (KeyError, TypeError) as e:
raise UnknownBackendResponse(e)

return await self.fetch_data(
user_subscription_parser,
USER_INFO_URL,
)
return await self.fetch_data(user_subscription_parser, USER_INFO_URL)

async def get_subscription_games(self) -> List[SubscriptionGame]:
return await self.fetch_data(PSNGamesParser().parse, PSN_PLUS_SUBSCRIPTIONS_URL, get_json=False, silent=True)

async def async_get_purchased_games(self):
def games_parser(response):
Expand All @@ -153,10 +118,7 @@ def games_parser(response):
except (KeyError, TypeError) as e:
raise UnknownBackendResponse(e)

return await self.fetch_paginated_data(games_parser, GAME_LIST_URL, "totalCount")

async def get_subscription_games(self) -> List[SubscriptionGame]:
return await self.fetch_data(PSNGamesParser().parse, PSN_PLUS_SUBSCRIPTIONS_URL, get_json=False, silent=True)
return await self.fetch_paginated_data(games_parser, GAME_LIST_URL, "purchasedTitlesRetrieve", "totalCount")

async def async_get_played_games(self):
def games_parser(response):
Expand All @@ -168,4 +130,4 @@ def games_parser(response):
except (KeyError, TypeError) as e:
raise UnknownBackendResponse(e)

return await self.fetch_paginated_data(games_parser, PLAYED_GAME_LIST_URL, 'totalItemCount')
return await self.fetch_data(games_parser, PLAYED_GAME_LIST_URL)
7 changes: 6 additions & 1 deletion src/version.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
__version__ = "0.34"
__version__ = "0.35"

__changelog__ = {
"unreleased": """
""",
"0.35": """
- Fix pagination of fetched purchased games
""",
"0.34": """
- Add refreshing cookies by doing request to playstation login website
- Fix logging by changing oauth which is the same as in web client for playstation.com
Expand Down
10 changes: 6 additions & 4 deletions tests/test_data.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,14 @@
]

BACKEND_GAME_TITLES = {
"start": 0,
"size": 11,
"totalResults": 11,
"data": {
"purchasedTitlesRetrieve": {
"games": PARSED_GAME_TITLES
"games": PARSED_GAME_TITLES,
"pageInfo": {
"start": 0,
"size": 11,
"totalCount": 11,
}
}
}
}
Expand Down
42 changes: 37 additions & 5 deletions tests/test_owned_games.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
import copy

import pytest
from galaxy.api.consts import LicenseType
from galaxy.api.errors import UnknownBackendResponse
from galaxy.api.types import Game, LicenseInfo
from galaxy.unittest.mock import async_return_value

from http_client import paginate_url
from psn_client import DEFAULT_LIMIT, GAME_LIST_URL
from tests.test_data import GAMES, BACKEND_GAME_TITLES, PARSED_GAME_TITLES


@pytest.mark.asyncio
@pytest.mark.parametrize("backend_response, games", [
pytest.param({"data": {"purchasedTitlesRetrieve": {"games": []}}}, [], id='no games'),
pytest.param({"data": {"purchasedTitlesRetrieve": {"games": [], "pageInfo":{"totalResults": 0}}}}, [], id='no games'),
pytest.param(BACKEND_GAME_TITLES, GAMES, id='multiple game: real case scenario'),
])
async def test_get_owned_games__only_purchased_games(
Expand All @@ -26,7 +28,6 @@ async def test_get_owned_games__only_purchased_games(
)

assert games == await authenticated_plugin.get_owned_games()
http_get.assert_called_once_with(paginate_url(GAME_LIST_URL, DEFAULT_LIMIT))


@pytest.mark.asyncio
Expand Down Expand Up @@ -70,4 +71,35 @@ async def test_bad_format(
with pytest.raises(UnknownBackendResponse):
await authenticated_plugin.get_owned_games()

http_get.assert_called_once_with(paginate_url(GAME_LIST_URL, DEFAULT_LIMIT))

async def test_fetch_200_purchased_games(
authenticated_plugin,
mocker,
http_get
):
mocker.patch(
"psn_client.PSNClient.async_get_played_games",
return_value=async_return_value([])
)
response_size = 100
purchased_games = [{"titleId": f"GAME_ID_{i}", "name": f"GAME_NAME_{i}"} for i in range(200)]
response = {
"data": {
"purchasedTitlesRetrieve": {
"games": [],
"pageInfo": {
"totalCount": len(purchased_games),
"size": response_size,
}
}
}
}
r1, r2 = copy.deepcopy(response), copy.deepcopy(response)
r1["data"]["purchasedTitlesRetrieve"]["games"] = purchased_games[:response_size]
r2["data"]["purchasedTitlesRetrieve"]["games"] = purchased_games[response_size:]
http_get.side_effect = [r1, r2]
expected_games = [
Game(game["titleId"], game["name"], [], LicenseInfo(LicenseType.SinglePurchase, None)) for game in purchased_games
]

assert await authenticated_plugin.get_owned_games() == expected_games
Loading

0 comments on commit 12b698f

Please sign in to comment.