11import json
22import logging
3- import time
43from collections import namedtuple
54from datetime import datetime
65from typing import Dict , List , NewType , Optional , Any , Tuple
76
8- import aiohttp
97from galaxy .api .errors import (
10- AccessDenied , AuthenticationRequired , BackendError , BackendNotAvailable , BackendTimeout , NetworkError ,
118 UnknownBackendResponse
129)
1310from galaxy .api .types import Achievement , SubscriptionGame , Subscription
14- from galaxy .http import HttpClient
15- from yarl import URL
1611from datetime import datetime
1712
18-
1913logger = logging .getLogger (__name__ )
2014logger .setLevel (logging .INFO )
2115
22-
2316MasterTitleId = NewType ("MasterTitleId" , str )
2417AchievementSet = NewType ("AchievementSet" , str )
2518OfferId = NewType ("OfferId" , str )
2922
3023SubscriptionDetails = namedtuple ('SubscriptionDetails' , ['tier' , 'end_time' ])
3124
32-
33- class CookieJar (aiohttp .CookieJar ):
34- def __init__ (self ):
35- super ().__init__ ()
36- self ._cookies_updated_callback = None
37-
38- def set_cookies_updated_callback (self , callback ):
39- self ._cookies_updated_callback = callback
40-
41- def update_cookies (self , cookies , url = URL ()):
42- super ().update_cookies (cookies , url )
43- if cookies and self ._cookies_updated_callback :
44- self ._cookies_updated_callback (list (self ))
45-
46-
47- class AuthenticatedHttpClient (HttpClient ):
48- def __init__ (self ):
49- self ._auth_lost_callback = None
50- self ._cookie_jar = CookieJar ()
51- self ._access_token = None
52- self ._last_access_token_success = None
53- self ._save_lats_callback = None
54- super ().__init__ (cookie_jar = self ._cookie_jar )
55-
56- def set_auth_lost_callback (self , callback ):
57- self ._auth_lost_callback = callback
58-
59- def set_cookies_updated_callback (self , callback ):
60- self ._cookie_jar .set_cookies_updated_callback (callback )
61-
62- async def authenticate (self , cookies ):
63- self ._cookie_jar .update_cookies (cookies )
64- await self ._get_access_token ()
65-
66- def is_authenticated (self ):
67- return self ._access_token is not None
68-
69- async def get (self , * args , ** kwargs ):
70- if not self ._access_token :
71- raise AccessDenied ("No access token" )
72-
73- try :
74- return await self ._authorized_get (* args , ** kwargs )
75- except (AuthenticationRequired , AccessDenied ):
76- await self ._refresh_token ()
77- return await self ._authorized_get (* args , ** kwargs )
78-
79- async def _authorized_get (self , * args , ** kwargs ):
80- headers = kwargs .setdefault ("headers" , {})
81- headers ["Authorization" ] = "Bearer {}" .format (self ._access_token )
82-
83- return await super ().request ("GET" , * args , ** kwargs )
84-
85- async def _refresh_token (self ):
86- try :
87- if self ._access_token is not None :
88- # diff method, once you have one access_token, you can get another one on refresh.
89- url = "https://accounts.ea.com/connect/auth"
90- params = {
91- "client_id" : "JUNO_PC_CLIENT" ,
92- "scope" : "signin dp.client.default" ,
93- "access_token" : self ._access_token ,
94- }
95- response = await super ().request ("GET" , url , params = params , allow_redirects = False )
96- if "access_token" in response .headers ["Location" ]:
97- data = response .headers ["Location" ]
98- # should look like qrc:/html/login_successful.html#access_token=
99- # note that there's some other parameters afterwards, so we need to isolate the variable well
100- self ._access_token = data .split ("#" )[1 ].split ("=" )[1 ].split ("&" )[0 ]
101- else :
102- await self ._get_access_token ()
103- except (BackendNotAvailable , BackendTimeout , BackendError , NetworkError ):
104- logger .warning ("Failed to refresh token for independent reasons" )
105- raise
106- except Exception :
107- logger .exception ("Failed to refresh token" )
108- self ._access_token = None
109- if self ._auth_lost_callback :
110- self ._auth_lost_callback ()
111- raise AccessDenied ("Failed to refresh token" )
112-
113- async def _get_access_token (self ):
114- url = "https://accounts.ea.com/connect/auth"
115- params = {
116- "client_id" : "JUNO_PC_CLIENT" ,
117- "display" : "junoWeb/login" ,
118- "response_type" : "token" ,
119- "redirectUri" : "nucleus:rest"
120- }
121- response = await super ().request ("GET" , url , params = params , allow_redirects = False )
122-
123- # upd 18.09.2023 : the access_token is in the "Location" header. It's a Bearer token.
124- if "access_token" in response .headers ["Location" ]:
125- data = response .headers ["Location" ]
126- # should look like qrc:/html/login_successful.html#access_token=
127- # note that there's some other parameters afterwards, so we need to isolate the variable well
128- self ._access_token = data .split ("#" )[1 ].split ("=" )[1 ].split ("&" )[0 ]
129- elif "access_token" not in response .headers ["Location" ] and "error=login_required" in response .headers ["Location" ]:
130- self ._log_session_details ()
131- raise AuthenticationRequired ("Error parsing access token. Must reauthenticate." )
132- else :
133- self ._save_lats ()
134-
135- # more logging for auth lost investigation
136-
137- def _save_lats (self ):
138- if self ._save_lats_callback is not None :
139- self ._last_access_token_success = int (time .time ())
140- self ._save_lats_callback (self ._last_access_token_success )
141-
142- def set_save_lats_callback (self , callback ):
143- self ._save_lats_callback = callback
144-
145- def load_lats_from_cache (self , value : Optional [str ]):
146- self ._last_access_token_success = int (value ) if value else None
147-
148- def _log_session_details (self ):
149- try :
150- utag_main_cookie = next (filter (lambda c : c .key == 'utag_main' , self ._cookie_jar ))
151- utag_main = {i .split (':' )[0 ]: i .split (':' )[1 ] for i in utag_main_cookie .value .split ('$' )}
152- logger .info ('now: %s st: %s ses_id: %s lats: %s' ,
153- str (int (time .time ())),
154- utag_main ['_st' ][:10 ],
155- utag_main ['ses_id' ][:10 ],
156- str (self ._last_access_token_success )
157- )
158- except Exception as e :
159- logger .warning ('Failed to get session duration: %s' , repr (e ))
160-
161-
16225class EABackendClient :
16326 def __init__ (self , http_client ):
16427 self ._http_client = http_client
@@ -171,24 +34,22 @@ def _get_api_host():
17134 async def get_identity (self ) -> Tuple [str , str , str ]:
17235 url = "{}?query=query{{me{{player{{pd psd displayName}}}}}}" .format (self ._get_api_host ())
17336 pid_response = await self ._http_client .get (url )
174- data = await pid_response .json ()
17537
17638 try :
177- user_id = data ["data" ]["me" ]["player" ]["pd" ]
178- persona_id = data ["data" ]["me" ]["player" ]["psd" ]
179- user_name = data ["data" ]["me" ]["player" ]["displayName" ]
39+ user_id = pid_response ["data" ]["me" ]["player" ]["pd" ]
40+ persona_id = pid_response ["data" ]["me" ]["player" ]["psd" ]
41+ user_name = pid_response ["data" ]["me" ]["player" ]["displayName" ]
18042
18143 return str (user_id ), str (persona_id ), str (user_name )
18244 except (AttributeError , KeyError ) as e :
183- logger .exception ("Can not parse backend response: %s, error %s" , data , repr (e ))
45+ logger .exception ("Can not parse backend response: %s, error %s" , pid_response , repr (e ))
18446 raise UnknownBackendResponse ()
18547
18648 async def get_entitlements (self ) -> List [Json ]:
18749 # Step 1 = get all Origin product IDs
18850 u1 = "{}?query=query{{me{{ownedGameProducts(locale:\" en\" entitlementEnabled:true storefronts:[EA,STEAM,EPIC] type:[DIGITAL_FULL_GAME,PACKAGED_FULL_GAME] platforms:[PC] paging:{{limit:9999}}){{items{{originOfferId product{{gameSlug baseItem {{gameType}} gameProductUser{{ownershipMethods entitlementId}}}}}}}}}}}}" .format (self ._get_api_host ())
189- r1 = await self ._http_client .get (u1 )
51+ d1 = await self ._http_client .get (u1 )
19052 try :
191- d1 = await r1 .json ()
19253 return d1 ['data' ]['me' ]['ownedGameProducts' ]['items' ]
19354 except (ValueError , KeyError ) as e :
19455 logger .exception ("Can not parse backend response: %s, error %s" , await d1 .text (), repr (e ))
@@ -203,8 +64,7 @@ async def get_offer(self, offer_id) -> Json:
20364 u2 = u2 .replace (' ' , '%20' ).replace ('+' , '%20' )
20465 response = await self ._http_client .get (u2 )
20566 try :
206- r2 = await response .json ()
207- return r2 ['data' ]['legacyOffers' ][0 ], r2 ['data' ]['gameProducts' ]['items' ][0 ]
67+ return response ['data' ]['legacyOffers' ][0 ], response ['data' ]['gameProducts' ]['items' ][0 ]
20868 except (ValueError , KeyError ) as e :
20969 logger .exception ("Can not parse backend response: %s, error %s" , await response .text (), repr (e ))
21070 raise UnknownBackendResponse ()
@@ -236,9 +96,8 @@ def parser(json_data: Dict) -> List[Achievement]:
23696 return achievements
23797
23898 try :
239- json = await response .json ()
24099 achievement_sets = {}
241- for achievement_set in json ["data" ]["achievements" ]:
100+ for achievement_set in response ["data" ]["achievements" ]:
242101 achievements = parser (achievement_set )
243102 achievement_sets [achievement_set ["id" ]] = achievements
244103 return achievement_sets
@@ -252,8 +111,7 @@ async def get_achievement_set(self, offer_id: OfferId, persona_id: str) -> str:
252111 response = await self ._http_client .get (url )
253112
254113 try :
255- json = await response .json ()
256- achievements = json ["data" ]["achievements" ]
114+ achievements = response ["data" ]["achievements" ]
257115 if achievements :
258116 return achievements [0 ]["id" ] if "id" in achievements [0 ] else None
259117 else :
@@ -288,6 +146,7 @@ async def get_game_time(self, game_slug):
288146 }
289147 }
290148 """
149+
291150 try :
292151 def parse_last_played_time (lastplayed_timestamp ) -> Optional [int ]:
293152 try :
@@ -297,13 +156,12 @@ def parse_last_played_time(lastplayed_timestamp) -> Optional[int]:
297156
298157 return int (time_delta .total_seconds ())
299158
300- content = await response .json ()
301159 # assuming this is just EA's way of saying we never played a game.
302- if not content ['data' ]['me' ]['recentGames' ]['items' ]:
160+ if not response ['data' ]['me' ]['recentGames' ]['items' ]:
303161 return 0 , None
304162 else :
305- total_play_time = round (int (content ['data' ]['me' ]['recentGames' ]['items' ][0 ]['totalPlayTimeSeconds' ]) / 60 ) # response is in seconds
306- last_played_time = parse_last_played_time (content ['data' ]['me' ]['recentGames' ]['items' ][0 ]['lastSessionEndDate' ])
163+ total_play_time = round (int (response ['data' ]['me' ]['recentGames' ]['items' ][0 ]['totalPlayTimeSeconds' ]) / 60 ) # response is in seconds
164+ last_played_time = parse_last_played_time (response ['data' ]['me' ]['recentGames' ]['items' ][0 ]['lastSessionEndDate' ])
307165
308166 return total_play_time , last_played_time
309167 except (AttributeError , ValueError , KeyError ) as e :
@@ -338,10 +196,9 @@ async def get_friends(self):
338196 """
339197
340198 try :
341- content = await response .json ()
342199 return {
343200 user_json ['player' ]['pd' ]: user_json ["player" ]["displayName" ]
344- for user_json in content ["data" ]["me" ]["friends" ]["items" ]
201+ for user_json in response ["data" ]["me" ]["friends" ]["items" ]
345202 }
346203 except (AttributeError , KeyError ):
347204 logger .exception ("Can not parse backend response: %s" , await response .text ())
@@ -382,8 +239,7 @@ def parse_last_session_end_date(date) -> int:
382239
383240
384241 try :
385- content = await response .json ()
386- games = content ["data" ]["me" ]["recentGames" ]["items" ]
242+ games = response ["data" ]["me" ]["recentGames" ]["items" ]
387243 return {
388244 game ["gameSlug" ]: parse_last_session_end_date (game ["lastSessionEndDate" ])
389245 for game in games
@@ -414,8 +270,7 @@ async def _get_subscription_uris(self) -> List[str]:
414270 url = "{}?query=query{{me{{subscriptions{{offerId recurring start end level status offer{{offerName duration}} platform type statusReasonCode acquisitionMethod}}}}}}" .format (self ._get_api_host ())
415271 response = await self ._http_client .get (url )
416272 try :
417- data = await response .json ()
418- return data ['data' ]['me' ]['subscriptions' ]
273+ return response ['data' ]['me' ]['subscriptions' ]
419274 except (ValueError , KeyError ) as e :
420275 logger .exception ("Can not parse backend response while getting subs uri: %s, error %s" , await response .text (), repr (e ))
421276 raise UnknownBackendResponse ()
@@ -451,17 +306,15 @@ async def get_games_in_subscription(self, tier) -> List[SubscriptionGame]:
451306 url = "{}?query=query{{gameSearch(filter:{{gameTypes:[BASE_GAME],productLifecycleFilter:{{lifecycleTypes:[{}]}}}},paging:{{limit:9999}}){{items{{slug}}}}}}" .format (self ._get_api_host (), tier )
452307 response = await self ._http_client .get (url )
453308 try :
454- slugs = await response .json ()
455- slugs = [game ['slug' ] for game in slugs ['data' ]['gameSearch' ]['items' ]]
309+ slugs = [game ['slug' ] for game in response ['data' ]['gameSearch' ]['items' ]]
456310 # we'll only get slugs, now get entitlement data
457311 subscription_games = [] # Create an empty list to accumulate the subscription games
458312 url2 = "{}?query=query{{games(slugs:{}){{items{{slug products{{items{{id name originOfferId}}}}}}}}}}" .format (
459313 self ._get_api_host (),
460314 json .dumps (slugs )
461315 )
462- res2 = await self ._http_client .get (url2 .replace (' ' , '%20' ).replace ('+' , '%20' ))
316+ games = await self ._http_client .get (url2 .replace (' ' , '%20' ).replace ('+' , '%20' ))
463317 try :
464- games = await res2 .json ()
465318 # verify product info, and take the correct Origin offer ID (some games have multiple offers)
466319 for game in games ['data' ]['games' ]['items' ]:
467320 if len (game ['products' ]['items' ]) == 1 :
0 commit comments