def __init__(self, reader, writer, token): super().__init__(Platform.Epic, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(store_credentials_callback=self.store_credentials) self._epic_client = EpicClient(self._http_client) self._local_provider = LocalGamesProvider() self._games_cache = {} self._refresh_owned_task = None
def test_empty_json(): items = {} with pytest.raises(UnknownBackendResponse): EpicClient._parse_catalog_item(items)
class EpicPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Epic, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(store_credentials_callback=self.store_credentials) self._epic_client = EpicClient(self._http_client) self._local_provider = LocalGamesProvider() self._local_client = local_client self._owned_games = {} self._game_info_cache = {} self._encoder = JSONEncoder() self._refresh_owned_task = None async def _do_auth(self): user_info = await self._epic_client.get_users_info([self._http_client.account_id]) display_name = self._epic_client.get_display_name(user_info) self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(self._http_client.account_id, display_name) async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) refresh_token = stored_credentials["refresh_token"] try: await self._http_client.authenticate_with_refresh_token(refresh_token) except (BackendNotAvailable, BackendError, BackendTimeout, NetworkError, UnknownError) as e: raise e except Exception: raise InvalidCredentials() return await self._do_auth() async def pass_login_credentials(self, step, credentials, cookies): try: if cookies: cookiez = {} for cookie in cookies: cookiez[cookie['name']] = cookie['value'] self._http_client.update_cookies(cookiez) exchange_code = await self._http_client.retrieve_exchange_code() await self._http_client.authenticate_with_exchange_code(exchange_code) except (BackendNotAvailable, BackendError, BackendTimeout, NetworkError, UnknownError) as e: raise e except Exception as e: log.error(repr(e)) raise InvalidCredentials() return await self._do_auth() def handshake_complete(self): self._game_info_cache = { k: GameInfo(**v) for k, v in json.loads(self.persistent_cache.get('game_info', '{}')).items() } def _store_cache(self, key, obj): self.persistent_cache[key] = self._encoder.encode(obj) self.push_cache() def store_credentials(self, credentials: dict): """Prevents losing credentials on `push_cache`""" self.persistent_cache['credentials'] = self._encoder.encode(credentials) super().store_credentials(credentials) def _get_dlcs(self, products): dlcs = [] for game in products['data']['Launcher']['libraryItems']['records']: try: if 'mainGameItem' in game['catalogItem'] and game['catalogItem']['mainGameItem']: dlcs.append(EpicDlc(game['catalogItem']['mainGameItem']['id'], game['catalogItemId'], game['catalogItem']['title'])) except (TypeError, KeyError) as e: log.error(f"Exception while trying to parse product {repr(e)}\nProduct {game}") return dlcs def _parse_owned_product(self, game, dlcs): games_dlcs = [] is_game = False is_application = False for category in game['catalogItem']['categories']: if category['path'] == 'games': is_game = True if category['path'] == 'applications': is_application = True if not is_game or not is_application: return for dlc in dlcs: if game['catalogItemId'] == dlc.parent_id: games_dlcs.append(Dlc(dlc.dlc_id, dlc.dlc_title, LicenseInfo(LicenseType.SinglePurchase))) if game['catalogItemId'] == dlc.dlc_id: # product is a dlc, skip return self._game_info_cache[game['appName']] = GameInfo(game['namespace'], game['appName'], game['catalogItem']['title']) return Game(game['appName'], game['catalogItem']['title'], games_dlcs, LicenseInfo(LicenseType.SinglePurchase)) async def _get_owned_games(self): parsed_games = [] owned_products = await self._epic_client.get_owned_games() dlcs = self._get_dlcs(owned_products) product_mapping = await self._epic_client.get_productmapping() for product in owned_products['data']['Launcher']['libraryItems']['records']: try: parsed_game = self._parse_owned_product(product, dlcs) if parsed_game: cached_game_info = self._game_info_cache.get(parsed_game.game_id) if cached_game_info.namespace in product_mapping: parsed_games.append(parsed_game) except (TypeError, KeyError) as e: log.error(f"Exception while trying to parse product {repr(e)}\nProduct {product}") self._store_cache('game_info', self._game_info_cache) return parsed_games async def get_owned_games(self): games = await self._get_owned_games() for game in games: self._owned_games[game.game_id] = game self._refresh_owned_task = asyncio.create_task(self._check_for_new_games(300)) return games async def get_local_games(self): if self._local_provider.first_run: self._local_provider.setup() return [ LocalGame(app_name, state) for app_name, state in self._local_provider.games.items() ] async def _get_store_slug(self, game_id): cached_game_info = self._game_info_cache.get(game_id) try: if cached_game_info: title = cached_game_info.title namespace = cached_game_info.namespace else: # extra safety fallback in case of dealing with removed game assets = await self._epic_client.get_assets() for asset in assets: if asset.app_name == game_id: if game_id in self._owned_games: title = self._owned_games[game_id].game_title else: details = await self._epic_client.get_catalog_items_with_id(asset.namespace, asset.catalog_id) title = details.title namespace = asset.namespace product_store_info = await self._epic_client.get_product_store_info(title) if "data" in product_store_info: for product in product_store_info["data"]["Catalog"]["catalogOffers"]["elements"]: if product["linkedOfferNs"] == namespace: return product['productSlug'] return "" except Exception as e: log.error(repr(e)) return "" async def open_epic_browser(self, store_slug=None): if store_slug: url = f"https://www.epicgames.com/store/install/{store_slug}" else: url = "https://www.epicgames.com/store/download" log.info(f"Opening Epic website {url}") webbrowser.open(url) def _is_game_installed(self, game_id): try: game_state = self._local_provider.games[game_id] if game_state is not LocalGameState.Installed: return False return True except KeyError: return False async def launch_game(self, game_id): if self._local_provider.is_game_running(game_id): log.info(f'Game already running, game_id: {game_id}.') return if SYSTEM == System.WINDOWS: cmd = f"com.epicgames.launcher://apps/{game_id}?action=launch^&silent=true" elif SYSTEM == System.MACOS: cmd = f"'com.epicgames.launcher://apps/{game_id}?action=launch&silent=true'" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser() else: await self._local_provider.search_process(game_id, timeout=30) async def uninstall_game(self, game_id): if not self._is_game_installed(game_id): log.warning("Received uninstall command on a not installed game") return cmd = "com.epicgames.launcher://store/library" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser(await self._get_store_slug(game_id)) async def install_game(self, game_id): if self._is_game_installed(game_id): log.warning(f"Game {game_id} is already installed") return await self.launch_game(game_id) cmd = "com.epicgames.launcher://store/library" try: await self._local_client.exec(cmd) except ClientNotInstalled: await self.open_epic_browser(await self._get_store_slug(game_id)) async def get_friends(self): ids = await self._epic_client.get_friends_list() account_ids = [] friends = [] prev_slice = 0 for index, entry in enumerate(ids): account_ids.append(entry["accountId"]) ''' Send request for friends information in batches of 50 so the request isn't too large, 50 is an arbitrary number, to be tailored if need be ''' if index + 1 % 50 == 0 or index == len(ids) - 1: friends.extend(await self._epic_client.get_users_info(account_ids[prev_slice:])) prev_slice = index friend_infos = [] for friend in friends: if "id" in friend and "displayName" in friend: friend_infos.append(FriendInfo(user_id=friend["id"], user_name=friend["displayName"])) elif "id" in friend: friend_infos.append(FriendInfo(user_id=friend["id"], user_name="")) return friend_infos def _update_local_game_statuses(self): updated = self._local_provider.consume_updated_games() for id_ in updated: new_state = self._local_provider.games[id_] log.debug(f'Updating game {id_} state to {new_state}') self.update_local_game_status(LocalGame(id_, new_state)) async def _check_for_new_games(self, interval): await asyncio.sleep(interval) log.info("Checking for new games") refreshed_owned_games = await self._get_owned_games() for game in refreshed_owned_games: if game.game_id not in self._owned_games: log.info(f"Found new game, {game}") self.add_game(game) self._owned_games[game.game_id] = game async def prepare_game_times_context(self, game_ids): return await self._epic_client.get_playtime() async def get_game_time(self, game_id, context): if context: playtime = context else: playtime = await self.prepare_game_times_context(None) time_played = None for item in playtime['data']['PlaytimeTracking']['total']: if item['artifactId'] == game_id and 'totalTime' in item: time_played = int(item['totalTime']/60) break return GameTime(game_id, time_played, None) async def prepare_local_size_context(self, game_ids) -> dict: return parse_manifests() async def get_local_size(self, game_id, context) -> int: try: game_manifest = context[game_id] return int(game_manifest['InstallSize']) except (KeyError, ValueError) as e: raise FailedParsingManifest(repr(e)) async def launch_platform_client(self): if self._local_provider.is_client_running: log.info("Epic client already running") return cmd = "com.epicgames.launcher:" await self._local_client.exec(cmd) asyncio.create_task(self._local_client.prevent_epic_from_showing()) async def shutdown_platform_client(self): await self._local_client.shutdown_platform_client() def tick(self): if not self._local_provider.first_run: self._update_local_game_statuses() if self._refresh_owned_task and self._refresh_owned_task.done(): # Interval set to 8 minutes because that makes the request number just below galaxy's own calls # and still maintains the functionality self._refresh_owned_task = asyncio.create_task(self._check_for_new_games(60*8)) async def shutdown(self): if self._local_provider._status_updater: self._local_provider._status_updater.cancel() if self._http_client: await self._http_client.close()
class EpicPlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.Epic, __version__, reader, writer, token) self._http_client = AuthenticatedHttpClient(store_credentials_callback=self.store_credentials) self._epic_client = EpicClient(self._http_client) self._local_provider = LocalGamesProvider() self._games_cache = {} self._refresh_owned_task = None async def _do_auth(self): user_info = await self._epic_client.get_users_info([self._http_client.account_id]) display_name = self._epic_client.get_display_name(user_info) self._http_client.set_auth_lost_callback(self.lost_authentication) return Authentication(self._http_client.account_id, display_name) async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS, js=AUTH_JS) refresh_token = stored_credentials["refresh_token"] try: await self._http_client.authenticate_with_refresh_token(refresh_token) except Exception: # TODO: distinguish between login-related and all other (networking, server, e.t.c.) errors raise InvalidCredentials() return await self._do_auth() async def pass_login_credentials(self, step, credentials, cookies): try: await self._http_client.authenticate_with_exchage_code( credentials["end_uri"].split(AUTH_REDIRECT_URL, 1)[1] ) except Exception: # TODO: distinguish between login-related and all other (networking, server, e.t.c.) errors raise InvalidCredentials() return await self._do_auth() async def _get_title_sanitized(self, app_name): if app_name in self._games_cache: return self._games_cache[app_name].game_title.replace(" ", "-").lower() log.debug('Nothing found, fallback to epic client') assets = await self._epic_client.get_assets() for asset in assets: if asset.app_name == app_name: details = await self._epic_client.get_catalog_items(asset.namespace, asset.catalog_id) return details.title.replace(" ", "-").lower() log.warning(f'Game {app_name} was not found in assets') raise UnknownBackendResponse() async def _get_owned_games(self): requests = [] assets = await self._epic_client.get_assets() for namespace, _, catalog_id in assets: requests.append(self._epic_client.get_catalog_items(namespace, catalog_id)) items = await asyncio.gather(*requests) games = [] for i, item in enumerate(items): if "games" not in item.categories: continue game = Game(assets[i].app_name, item.title, None, LicenseInfo(LicenseType.SinglePurchase)) games.append(game) return games async def get_owned_games(self): games = await self._get_owned_games() for game in games: self._games_cache[game.game_id] = game self._refresh_owned_task = asyncio.create_task(self._check_for_new_games()) return games async def get_local_games(self): if self._local_provider.first_run: self._local_provider.setup() return [ LocalGame(app_name, state) for app_name, state in self._local_provider.games.items() ] async def open_epic_browser(self, game_id): try: title = await self._get_title_sanitized(game_id) title = title.replace(" ", "-").lower() except UnknownBackendResponse: url = "https://www.epicgames.com/" else: url = f"https://www.epicgames.com/store/product/{title}/home" log.info(f"Opening Epic website {url}") webbrowser.open(url) @property def _open(self): if SYSTEM == System.WINDOWS: return "start" elif SYSTEM == System.MACOS: return "open" async def launch_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return if self._local_provider.is_game_running(game_id): log.info('Game already running.') return cmd = f"{self._open} com.epicgames.launcher://apps/{game_id}?action=launch^&silent=true" log.info(f"Launching game {game_id}") subprocess.Popen(cmd, shell=True) await self._local_provider.search_process(game_id, timeout=30) async def uninstall_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return title = await self._get_title_sanitized(game_id) cmd = f"{self._open} com.epicgames.launcher://store/product/{title}/home" log.info(f"Uninstalling game {title}") subprocess.Popen(cmd, shell=True) async def install_game(self, game_id): if not self._local_provider.is_launcher_installed: await self.open_epic_browser(game_id) return title = await self._get_title_sanitized(game_id) cmd = f"{self._open} com.epicgames.launcher://store/product/{title}/home" log.info(f"Installing game {title}") subprocess.Popen(cmd, shell=True) async def get_friends(self): ids = await self._epic_client.get_friends_list() account_ids = [] friends = [] prev_slice = 0 log.debug(ids) for index, entry in enumerate(ids): account_ids.append(entry["accountId"]) ''' Send request for friends information in batches of 50 so the request isn't too large, 50 is an arbitrary number, to be tailored if need be ''' if index + 1 % 50 == 0 or index == len(ids) - 1: friends.extend(await self._epic_client.get_users_info(account_ids[prev_slice:])) prev_slice = index return[ FriendInfo(user_id=friend["id"], user_name=friend["displayName"]) for friend in friends ] def _update_local_game_statuses(self): updated = self._local_provider.consume_updated_games() for id_ in updated: new_state = self._local_provider.games[id_] log.debug(f'Updating game {id_} state to {new_state}') self.update_local_game_status(LocalGame(id_, new_state)) async def _check_for_new_games(self): await asyncio.sleep(60) # interval log.info("Checking for new games") assets = await self._epic_client.get_assets() for namespace, app_name, catalog_id in assets: if app_name not in self._games_cache and namespace != "ue": details = await self._epic_client.get_catalog_items(namespace, catalog_id) if "games" not in details.categories: continue game = Game(app_name, details.title, None, LicenseInfo(LicenseType.SinglePurchase)) log.info(f"Found new game, {game}") self.add_game(game) self._games_cache[game.game_id] = game def tick(self): if not self._local_provider.first_run: self._update_local_game_statuses() if self._refresh_owned_task and self._refresh_owned_task.done(): self._refresh_owned_task = asyncio.create_task(self._check_for_new_games()) def shutdown(self): self._local_provider._status_updater.cancel() asyncio.create_task(self._http_client.close())