Skip to content

Commit

Permalink
version 0.28
Browse files Browse the repository at this point in the history
  • Loading branch information
FriendsOfGalaxy committed Apr 7, 2020
1 parent 1996d50 commit fedef01
Show file tree
Hide file tree
Showing 13 changed files with 390 additions and 38 deletions.
4 changes: 1 addition & 3 deletions requirements/app.txt
Original file line number Diff line number Diff line change
@@ -1,4 +1,2 @@
aiohttp==3.5.4
galaxy.plugin.api==0.62
urllib3==1.24.1
galaxy.plugin.api==0.64
certifi==2019.3.9
2 changes: 1 addition & 1 deletion requirements/dev.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ pytest-pythonpath==0.7.3
pytest-asyncio==0.10.0
pytest-mock==1.10.3
cryptography==2.5
pip-tools==4.3.0
pip-tools==4.5.0
52 changes: 30 additions & 22 deletions src/http_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,38 @@ def paginate_url(url, limit, offset=0):
return url + "&limit={limit}&offset={offset}".format(limit=limit, offset=offset)


class AuthenticatedHttpClient:
class HttpClient:
def __init__(self):
self._session = create_client_session(timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))

async def request(self, method, *args, **kwargs):
with handle_exception():
return await self._session.request(method, *args, **kwargs)

async def get(self, url, *args, **kwargs):
silent = kwargs.pop('silent', False)
response = await self.request("GET", *args, url=url, **kwargs)
try:
raw_response = '***' if silent else await response.text()
logging.debug("Response for:\n{url}\n{data}".format(url=url, data=raw_response))
return await response.json()
except ValueError:
logging.exception("Invalid response data for:\n{url}".format(url=url))
raise UnknownBackendResponse()

async def post(self, url, *args, **kwargs):
logging.debug("Sending data:\n{url}".format(url=url))
response = await self.request("POST", *args, url=url, **kwargs)
logging.debug("Response for post:\n{url}\n{data}".format(url=url, data=await response.text()))
return response


class AuthenticatedHttpClient(HttpClient):
def __init__(self, auth_lost_callback):
self._access_token = None
self._refresh_token = None
self._auth_lost_callback = auth_lost_callback
self._session = create_client_session(timeout=aiohttp.ClientTimeout(total=DEFAULT_TIMEOUT))
super().__init__()

@property
def is_authenticated(self):
Expand All @@ -87,7 +113,7 @@ def _validate_auth_response(self, location_params):
async def get_access_token(self, refresh_token):
response = None
try:
response = await self._request(
response = await super().request(
"GET",
url=OAUTH_TOKEN_URL,
cookies={"npsso": refresh_token},
Expand Down Expand Up @@ -127,7 +153,6 @@ async def _refresh_access_token(self):
async def request(self, method, *args, **kwargs):
if not self._access_token:
raise AuthenticationRequired()

try:
return await self._oauth_request(method, *args, **kwargs)
except AuthenticationRequired:
Expand All @@ -137,24 +162,7 @@ async def request(self, method, *args, **kwargs):
async def _oauth_request(self, method, *args, **kwargs):
headers = kwargs.setdefault("headers", {})
headers["authorization"] = "Bearer " + self._access_token
return await self._request(method, *args, **kwargs)

async def _request(self, method, *args, **kwargs):
with handle_exception():
return await self._session.request(method, *args, **kwargs)

async def get(self, url, *args, **kwargs):
response = await self.request("GET", *args, url=url, **kwargs)
try:
logging.debug("Response for:\n{url}\n{data}".format(url=url, data=await response.text()))
return await response.json()
except ValueError:
logging.exception("Invalid response data for:\n{url}".format(url=url))
raise UnknownBackendResponse()

async def post(self, url, *args, **kwargs):
logging.debug("Sending data:\n{url}".format(url=url))
return await self.request("POST", *args, url=url, **kwargs)
return await super().request(method, *args, **kwargs)

async def logout(self):
await self._session.close()
16 changes: 12 additions & 4 deletions src/plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from collections import defaultdict

from galaxy.api.plugin import Plugin, create_and_run_plugin
from galaxy.api.types import Authentication, NextStep, Achievement, UserPresence, PresenceState
from galaxy.api.types import Authentication, NextStep, Achievement, UserPresence, PresenceState, SubscriptionGame, Subscription
from galaxy.api.consts import Platform
from galaxy.api.errors import ApplicationError, InvalidCredentials, UnknownError
from galaxy.api.jsonrpc import InvalidParams
Expand All @@ -19,7 +19,7 @@
CommunicationId, TitleId, TrophyTitles, UnixTimestamp,
PSNClient, MAX_TITLE_IDS_PER_REQUEST
)
from typing import Dict, List, Set, Iterable, Tuple, Optional, Any
from typing import Dict, List, Set, Iterable, Tuple, Optional, Any, AsyncGenerator
from version import __version__

from http_client import OAUTH_LOGIN_URL, OAUTH_LOGIN_REDIRECT_URL
Expand Down Expand Up @@ -113,6 +113,14 @@ async def get_game_communication_ids(self, title_ids: List[TitleId]) -> Dict[Tit

return result

async def get_subscriptions(self) -> List[Subscription]:
is_plus_active = await self._psn_client.get_psplus_status()
return [Subscription(subscription_name="PlayStation PLUS", end_time=None, owned=is_plus_active)]

async def get_subscription_games(self, subscription_name: str, context: Any) -> AsyncGenerator[List[SubscriptionGame], None]:
account_info = await self._psn_client.get_account_info()
yield await self._psn_client.get_subscription_games(account_info)

async def get_owned_games(self):
async def filter_games(titles):
comm_id_map = await self.get_game_communication_ids([t.game_id for t in titles])
Expand Down Expand Up @@ -233,8 +241,8 @@ async def get_user_presence(self, user_id: str, context: Any) -> UserPresence:
async def get_friends(self):
return await self._psn_client.async_get_friends()

def shutdown(self):
asyncio.create_task(self._http_client.logout())
async def shutdown(self):
await self._http_client.logout()

def handshake_complete(self):
trophies_cache = self.persistent_cache.get(TROPHIES_CACHE_KEY)
Expand Down
49 changes: 47 additions & 2 deletions src/psn_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
from typing import Dict, List, NewType, Tuple

from galaxy.api.errors import UnknownBackendResponse
from galaxy.api.types import Achievement, Game, LicenseInfo, UserInfo, UserPresence, PresenceState
from galaxy.api.types import Achievement, Game, LicenseInfo, UserInfo, UserPresence, PresenceState, SubscriptionGame
from galaxy.api.consts import LicenseType
from http_client import paginate_url
from psn_store import PSNFreePlusStore, AccountUserInfo

# game_id_list is limited to 5 IDs per request
GAME_DETAILS_URL = "https://pl-tpy.np.community.playstation.net/trophy/v1/apps/trophyTitles" \
Expand Down Expand Up @@ -37,13 +38,17 @@
USER_INFO_URL = "https://pl-prof.np.community.playstation.net/userProfile/v1/users/{user_id}/profile2" \
"?fields=accountId,onlineId"

USER_INFO_PSPLUS_URL = "https://pl-prof.np.community.playstation.net/userProfile/v1/users/{user_id}/profile2" \
"?fields=plus"

DEFAULT_AVATAR_SIZE = "l"
FRIENDS_URL = "https://us-prof.np.community.playstation.net/userProfile/v1/users/{user_id}/friends/profiles2" \
"?fields=accountId,onlineId,avatarUrls&avatarSizes={avatar_size_list}"

FRIENDS_WITH_PRESENCE_URL = "https://us-prof.np.community.playstation.net/userProfile/v1/users/{user_id}/friends/profiles2" \
"?fields=accountId,onlineId,primaryOnlineStatus,presences(@titleInfo,lastOnlineDate)"

ACCOUNTS_URL = "https://accounts.api.playstation.com/api/v1/accounts/{user_id}"

DEFAULT_LIMIT = 100
MAX_TITLE_IDS_PER_REQUEST = 5
Expand All @@ -53,10 +58,15 @@
UnixTimestamp = NewType("UnixTimestamp", int)
TrophyTitles = Dict[CommunicationId, UnixTimestamp]


def parse_timestamp(earned_date) -> UnixTimestamp:
dt = datetime.strptime(earned_date, "%Y-%m-%dT%H:%M:%SZ")
dt = datetime.combine(dt.date(), dt.time(), timezone.utc)
return UnixTimestamp(dt.timestamp())
return UnixTimestamp(int(dt.timestamp()))


def date_today():
return datetime.today()


class PSNClient:
Expand Down Expand Up @@ -108,13 +118,26 @@ async def fetch_data(self, parser, *args, **kwargs):

async def async_get_own_user_info(self):
def user_info_parser(response):
logging.debug(f'user profile data: {response}')
return response["profile"]["accountId"], response["profile"]["onlineId"]

return await self.fetch_data(
user_info_parser,
USER_INFO_URL.format(user_id="me")
)

async def get_psplus_status(self) -> bool:
def user_subscription_parser(response):
status = response["profile"]["plus"]
if status in [0, 1, True, False]:
return bool(status)
raise TypeError

return await self.fetch_data(
user_subscription_parser,
USER_INFO_PSPLUS_URL.format(user_id="me")
)

async def async_get_owned_games(self):
def game_parser(title):
return Game(
Expand Down Expand Up @@ -255,3 +278,25 @@ def friends_with_presence_parser(response):
FRIENDS_WITH_PRESENCE_URL.format(user_id="me"),
"totalResults"
)

async def get_account_info(self) -> AccountUserInfo:
def account_user_parser(data):
td = date_today() - datetime.fromisoformat(data['dateOfBirth'])
age = td.days // 365
return AccountUserInfo(data['region'], data['legalCountry'], data['language'], age)

return await self.fetch_data(account_user_parser, ACCOUNTS_URL.format(user_id='me'), silent=True)

async def get_subscription_games(self, account_info: AccountUserInfo) -> List[SubscriptionGame]:
def games_parser(data):
return [
SubscriptionGame(
game_id=item['id'].split('-')[1],
game_title=item['attributes']['name']
)
for item in data['included']
if item['type'] in ['game', 'game-related']
]

store = PSNFreePlusStore(self._http_client, account_info)
return await self.fetch_data(games_parser, store.games_container_url)
49 changes: 49 additions & 0 deletions src/psn_store.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from dataclasses import dataclass


@dataclass
class AccountUserInfo:
region: str
country: str
language: str
age: int


class PSNFreePlusStore:
BASE_URL = 'https://store.playstation.com/'
GAMES_CONTAINTER_URL = BASE_URL + 'valkyrie-api/{language}/{country}/{age}/container/{id}'
SESSION = BASE_URL + 'kamaji/api/valkyrie_storefront/00_09_000/user/session'
SUBSCRIPTION_DETAILS = BASE_URL + 'kamaji/api/valkyrie_storefront/00_09_000/gateway/store/v1/users/me/subscription/IP9102-NPIA90006_00'

PSPLUS_FREEGAMES_REGION_STORE = {
'SCEA': 'STORE-MSF77008-PSPLUSFREEGAMES',
'SCEE': 'STORE-MSF75508-PLUSINSTANTGAME',
'SCE-ASIA': 'STORE-MSF86012-PLUS_FTT_CONTENT',
'SCEJ': 'PN.CH.JP-PN.CH.MIXED.JP-PSPLUSFREEPLAY',
'SCEK': 'STORE-MSF86012-PLUS_FTT_KR'
}

def __init__(self, http_client, user: AccountUserInfo):
self._http_client = http_client
self.id = self.PSPLUS_FREEGAMES_REGION_STORE[user.region]
self.country = user.country
self.language = user.language[:2]
self.age = 21 if user.age > 21 else user.age

@property
def games_container_url(self):
return self.GAMES_CONTAINTER_URL.format(
language=self.language, country=self.country, age=self.age, id=self.id)

async def get_subscription_info(self):
"""This provides for information about user subscription data such as 'date_end'
TODO: use it in plugin.get_subscriptions to provide extended info about subscritpion
"""
await self._refresh_session()
return await self._http_client.get(self.SUBSCRIPTION_DETAILS)

async def _refresh_session(self):
"""TODO: generate auth code from logged-in user context"""
body = 'code=' # + code
res = await self._http_client.post(self.SESSION, data=body)
return await res.json()
8 changes: 7 additions & 1 deletion src/version.py
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
__version__ = "0.27"
__version__ = "0.28"

__changelog__ = {
"0.28": """
- Implements `get_subscription_games` to report current free games from PSPlus
"""
}
17 changes: 16 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,21 @@ def user_profile(online_id, account_id):
return {"profile": {"onlineId": online_id, "accountId": account_id}}


@pytest.fixture()
def psplus_active_status():
return 1


@pytest.fixture()
def user_profile_psplus(psplus_active_status):
return {"profile": {"plus": psplus_active_status}}


@pytest.fixture()
def psplus_name():
return "PlayStation PLUS"


@pytest.fixture()
def http_get(mocker):
return mocker.patch(
Expand All @@ -48,7 +63,7 @@ async def psn_plugin():
plugin = PSNPlugin(MagicMock(), MagicMock(), None)
yield plugin

plugin.shutdown()
await plugin.shutdown()


@pytest.fixture()
Expand Down
2 changes: 2 additions & 0 deletions tests/integration_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ def test_integration():
'ImportAchievements',
'ImportOwnedGames',
'ImportUserPresence',
'ImportSubscriptions',
'ImportSubscriptionGames',
'ImportFriends'
])
assert response["result"]["token"] == token
Expand Down
Loading

0 comments on commit fedef01

Please sign in to comment.