def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._download_resolver = HumbleDownloadResolver() self._app_finder = AppFinder() self._settings = Settings() self._library_resolver = None self._subscription_months: List[ChoiceMonth] = [] self._owned_games: Dict[str, HumbleGame] = {} self._trove_games: Dict[str, TroveGame] = {} self._choice_games = { } # for now model.subscription.ChoiceContet or Extras TODO consider adding to model.game self._local_games = {} self._cached_game_states = {} self._getting_owned_games = asyncio.Lock() self._owned_check: asyncio.Task = asyncio.create_task(asyncio.sleep(8)) self._statuses_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._installed_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._rescan_needed = True self._under_installation = set()
def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._download_resolver = HumbleDownloadResolver() self._app_finder = AppFinder self._settings = None self._library_resolver = None self._owned_games = {} self._local_games = {} self._cached_game_states = {} self._getting_owned_games = asyncio.Event() self._check_owned_task = asyncio.create_task(asyncio.sleep(0)) self._check_installed_task = asyncio.create_task(asyncio.sleep(5)) self._check_statuses_task = asyncio.create_task(asyncio.sleep(2)) self.__under_instalation = set()
def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._download_resolver = HumbleDownloadResolver() self._app_finder = AppFinder() self._settings = Settings() self._library_resolver = None self._owned_games = {} self._local_games = {} self._cached_game_states = {} self._getting_owned_games = asyncio.Lock() self._owned_check: asyncio.Task = asyncio.create_task(asyncio.sleep(8)) self._statuses_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._installed_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._rescan_needed = True self._under_installation = set()
class HumbleBundlePlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._download_resolver = HumbleDownloadResolver() self._app_finder = AppFinder() self._settings = Settings() self._library_resolver = None self._subscription_months: List[ChoiceMonth] = [] self._owned_games: Dict[str, HumbleGame] = {} self._trove_games: Dict[str, TroveGame] = {} self._choice_games = { } # for now model.subscription.ChoiceContet or Extras TODO consider adding to model.game self._local_games = {} self._cached_game_states = {} self._getting_owned_games = asyncio.Lock() self._owned_check: asyncio.Task = asyncio.create_task(asyncio.sleep(8)) self._statuses_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._installed_check: asyncio.Task = asyncio.create_task( asyncio.sleep(4)) self._rescan_needed = True self._under_installation = set() @property def _humble_games(self) -> t.Dict[str, HumbleGame]: """Alias for cached owned and subscription games mapped by id""" return {**self._owned_games, **self._trove_games} def _save_cache(self, key: str, data: t.Any): data = json.dumps(data) self.persistent_cache[key] = data self.push_cache() def _load_cache(self, key: str, default: t.Any = None) -> t.Any: if key in self.persistent_cache: return json.loads(self.persistent_cache[key]) return default def handshake_complete(self): self._last_version = self._load_cache('last_version', default=None) self._trove_games = { g.machine_name: g for g in self._load_cache('trove_games', []) } self._library_resolver = LibraryResolver( api=self._api, settings=self._settings.library, cache=self._load_cache('library', {}), save_cache_callback=partial(self._save_cache, 'library')) async def _fetch_marketing_data(self) -> t.Optional[str]: try: subscription_infos = await self._api.get_choice_marketing_data() self._subscription_months = subscription_infos.month_details return subscription_infos.user_options['email'] except KeyError: # extra safety as this data is not crucial return None async def authenticate(self, stored_credentials=None): show_news = self.__is_after_minor_update() self._save_cache('last_version', __version__) if not stored_credentials: return NextStep( "web_session", { "window_title": "Login to HumbleBundle", "window_width": 560, "window_height": 610, "start_uri": "https://www.humblebundle.com/login?goto=/home/library", "end_uri_regex": "^" + re.escape("https://www.humblebundle.com/home/library") }) logging.info('Stored credentials found') user_id = await self._api.authenticate(stored_credentials) user_email = await self._fetch_marketing_data() if show_news: self._open_config(OPTIONS_MODE.NEWS) return Authentication(user_id, user_email or user_id) async def pass_login_credentials(self, step, credentials, cookies): auth_cookie = next( filter(lambda c: c['name'] == '_simpleauth_sess', cookies)) user_id = await self._api.authenticate(auth_cookie) user_email = await self._fetch_marketing_data() self.store_credentials(auth_cookie) self._open_config(OPTIONS_MODE.WELCOME) return Authentication(user_id, user_email or user_id) def __is_after_minor_update(self) -> bool: def cut_to_minor(ver: str) -> LooseVersion: """3 part version assumed""" return LooseVersion(ver.rsplit('.', 1)[0]) return self._last_version is None \ or cut_to_minor(__version__) > cut_to_minor(self._last_version) async def get_owned_games(self): if not self._api.is_authenticated: raise AuthenticationRequired() async with self._getting_owned_games: logging.debug('getting owned games') self._owned_games = await self._library_resolver() return [g.in_galaxy_format() for g in self._owned_games.values()] @staticmethod def _normalize_subscription_name(machine_name): month_map = { 'january': '01', 'february': '02', 'march': '03', 'april': '04', 'may': '05', 'june': '06', 'july': '07', 'august': '08', 'september': '09', 'octover': '10', 'november': '11', 'december': '12' } month, year, type_ = machine_name.split('_') return f'Humble {type_.title()} {year}-{month_map[month]}' async def _get_current_user_subscription_plan( self, active_month_path: str) -> t.Optional[dict]: active_month_content = await self._api.get_choice_content_data( active_month_path) return active_month_content.user_subscription_plan async def get_subscriptions(self): subscriptions: List[Subscription] = [] active_content_unlocked = False current_or_former_subscriber = await self._api.had_subscription() if current_or_former_subscriber: async for product in self._api.get_subscription_products_with_gamekeys( ): subscriptions.append( Subscription(self._normalize_subscription_name( product.product_machine_name), owned=True)) if product.is_active_content: # assuming there is one "active" month at a time active_content_unlocked = True if not active_content_unlocked: ''' - for not subscribers as potential discovery of current choice games - for subscribers who has not used "Early Unlock" yet: https://support.humblebundle.com/hc/en-us/articles/217300487-Humble-Choice-Early-Unlock-Games ''' active_month = next( filter(lambda m: m.is_active == True, self._subscription_months)) current_user_plan = None if current_or_former_subscriber: current_user_plan = await self._get_current_user_subscription_plan( active_month.last_url_part) subscriptions.append( Subscription( self._normalize_subscription_name( active_month.machine_name), owned=bool(current_user_plan), # #116: exclude Lite end_time= None # #117: get_last_friday.timestamp() if user_plan not in [None, Lite] else None )) subscriptions.append( Subscription(subscription_name="Humble Trove", owned=active_content_unlocked or current_user_plan is not None)) return subscriptions async def _get_trove_games(self): def parse_and_cache(troves): games: List[SubscriptionGame] = [] for trove in troves: try: trove_game = TroveGame(trove) games.append(trove_game.in_galaxy_format()) self._trove_games[trove_game.machine_name] = trove_game except Exception as e: logging.warning( f"Error while parsing trove {repr(e)}: {trove}", extra={'data': trove}) return games newly_added = (await self._api.get_montly_trove_data()).get( 'newlyAdded', []) if newly_added: yield parse_and_cache(newly_added) async for troves in self._api.get_trove_details(): yield parse_and_cache(troves) async def prepare_subscription_games_context( self, subscription_names) -> t.Dict[str, ChoiceMonth]: name_url = {} for month in self._subscription_months: name_url[self._normalize_subscription_name( month.machine_name)] = month return name_url async def get_subscription_games(self, subscription_name, context: t.Dict[str, ChoiceMonth]): if subscription_name == "Humble Trove": async for troves in self._get_trove_games(): yield troves return month: ChoiceMonth = context[subscription_name] choice_data = await self._api.get_choice_content_data( month.last_url_part) cco = choice_data.content_choice_options show_all = cco.remained_choices > 0 if month.is_active: start_time = choice_data.active_content_start.timestamp() else: start_time = None # TODO assume first friday of month content_choices = [ SubscriptionGame(ch.title, ch.id, start_time) for ch in cco.content_choices if show_all or ch.id in cco.content_choices_made ] extrases = [ SubscriptionGame(extr.human_name, extr.machine_name, start_time) for extr in cco.extrases ] month_choice_games = content_choices + extrases yield month_choice_games async def subscription_games_import_complete(self): sub_games_raw_data = [ game.serialize() for game in self._trove_games.values ] self._save_cache('trove_games', sub_games_raw_data) async def get_local_games(self): self._rescan_needed = True return [g.in_galaxy_format() for g in self._local_games.values()] def _open_config(self, mode: OPTIONS_MODE = OPTIONS_MODE.NORMAL): """Synchonious wrapper for self._open_config_async""" self.create_task(self._open_config_async(mode), 'opening config') async def _open_config_async(self, mode: OPTIONS_MODE): try: await gui.show_options(mode) except Exception as e: logging.exception(e) self._settings.save_config() self._settings.open_config_file() @double_click_effect(timeout=0.5, effect='_open_config') async def install_game(self, game_id): if game_id in self._under_installation: return self._under_installation.add(game_id) try: game = self._humble_games.get(game_id) if game is None: logging.error( f'Install game: game {game_id} not found. Owned games: {self._humble_games.keys()}' ) return if isinstance(game, Key): try: await gui.show_key(game) except Exception as e: logging.error(e, extra={'platform_info': platform.uname()}) webbrowser.open('https://www.humblebundle.com/home/keys') return try: hp = HP.WINDOWS if IS_WINDOWS else HP.MAC curr_os_download = game.downloads[hp] except KeyError: raise UnknownError( f'{game.human_name} has only downloads for {list(game.downloads.keys())}' ) if isinstance(game, Subproduct): chosen_download_struct = self._download_resolver( curr_os_download) urls = await self._api.sign_url_subproduct( chosen_download_struct, curr_os_download.machine_name) webbrowser.open(urls['signed_url']) if isinstance(game, TroveGame): try: urls = await self._api.sign_url_trove( curr_os_download, game.machine_name) except AuthenticationRequired: logging.info( 'Looks like your Humble Monthly subscription has expired.' ) webbrowser.open( 'https://www.humblebundle.com/subscription/home') else: webbrowser.open(urls['signed_url']) except Exception as e: logging.error(e, extra={'game': game}) raise finally: self._under_installation.remove(game_id) async def get_game_library_settings(self, game_id: str, context: t.Any) -> GameLibrarySettings: gls = GameLibrarySettings(game_id, None, None) game = self._humble_games[game_id] if isinstance(game, Key): gls.tags = ['Key'] if game.key_val is None: gls.tags.append('Unrevealed') if isinstance(game, TroveGame): gls.tags = [ ] # remove redundant tags since Galaxy support for subscripitons return gls async def launch_game(self, game_id): try: game = self._local_games[game_id] except KeyError as e: logging.error(e, extra={'local_games': self._local_games}) else: game.run() async def uninstall_game(self, game_id): try: game = self._local_games[game_id] except KeyError as e: logging.error(e, extra={'local_games': self._local_games}) else: game.uninstall() async def get_os_compatibility( self, game_id: str, context: t.Any) -> t.Optional[OSCompatibility]: try: game = self._humble_games[game_id] except KeyError as e: # silent issues until support for choice games in #93 # logging.debug(self._humble_games) # logging.error(e, extra={'humble_games': self._humble_games}) return None else: HP_OS_MAP = { HP.WINDOWS: OSCompatibility.Windows, HP.MAC: OSCompatibility.MacOS, HP.LINUX: OSCompatibility.Linux } osc = OSCompatibility(0) for humble_platform in game.downloads: osc |= HP_OS_MAP.get(humble_platform, OSCompatibility(0)) return osc if osc else None async def _check_owned(self): async with self._getting_owned_games: old_ids = self._owned_games.keys() self._owned_games = await self._library_resolver(only_cache=True) for old_id in old_ids - self._owned_games.keys(): self.remove_game(old_id) for new_id in self._owned_games.keys() - old_ids: self.add_game(self._owned_games[new_id].in_galaxy_format()) # increased throttle to protect Galaxy from quick & heavy library changes await asyncio.sleep(3) async def _check_installed(self): """ Owned games are needed to local games search. Galaxy methods call order is: get_local_games -> authenticate -> get_local_games -> get_owned_games (at the end!). That is why the plugin sets all logic of getting local games in perdiodic checks like this one. """ if not self._humble_games: logging.debug( 'Skipping perdiodic check for local games as owned/subscription games not found yet.' ) return hp = HP.WINDOWS if IS_WINDOWS else HP.MAC installable_title_id = { game.human_name: uid for uid, game in self._humble_games.items() if not isinstance(game, Key) and game.os_compatibile(hp) } if self._rescan_needed: self._rescan_needed = False logging.debug( f'Checking installed games with path scanning in: {self._settings.installed.search_dirs}' ) self._local_games = await self._app_finder( installable_title_id, self._settings.installed.search_dirs) else: self._local_games.update(await self._app_finder(installable_title_id, None)) await asyncio.sleep(4) async def _check_statuses(self): """Checks satuses of local games. Detects changes in local games when the game is: - installed (local games list appended in _check_installed) - uninstalled (exe no longer exists) - launched (via Galaxy - pid tracking started) - stopped (process no longer running/is zombie) """ freezed_locals = list(self._local_games.values()) for game in freezed_locals: state = game.state if state == self._cached_game_states.get(game.id): continue self.update_local_game_status(LocalGame(game.id, state)) self._cached_game_states[game.id] = state await asyncio.sleep(0.5) def tick(self): self._settings.reload_config_if_changed() if self._owned_check.done() and self._settings.library.has_changed(): self._owned_check = self.create_task(self._check_owned(), 'check owned') if self._settings.installed.has_changed(): self._rescan_needed = True if self._installed_check.done(): self._installed_check = asyncio.create_task( self._check_installed()) if self._statuses_check.done(): self._statuses_check = asyncio.create_task(self._check_statuses()) async def shutdown(self): self._statuses_check.cancel() self._installed_check.cancel() await self._api.close_session()
class HumbleBundlePlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._download_resolver = HumbleDownloadResolver() self._app_finder = AppFinder self._settings = None self._library_resolver = None self._owned_games = {} self._local_games = {} self._cached_game_states = {} self._getting_owned_games = asyncio.Event() self._check_owned_task = asyncio.create_task(asyncio.sleep(0)) self._check_installed_task = asyncio.create_task(asyncio.sleep(5)) self._check_statuses_task = asyncio.create_task(asyncio.sleep(2)) self.__under_instalation = set() def _save_cache(self, key: str, data: Any): if type(data) != str: data = json.dumps(data) self.persistent_cache[key] = data self.push_cache() def handshake_complete(self): # tmp migration to fix 0.4.0 cache error library = json.loads(self.persistent_cache.get('library', '{}')) if library and type(library.get('orders')) == list: logging.info('Old cache migration') self._save_cache('library', {}) self._settings = Settings( cache=self.persistent_cache, save_cache_callback=self.push_cache ) self._library_resolver = LibraryResolver( api=self._api, settings=self._settings.library, cache=json.loads(self.persistent_cache.get('library', '{}')), save_cache_callback=partial(self._save_cache, 'library') ) async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) logging.info('Stored credentials found') user_id, user_name = await self._api.authenticate(stored_credentials) return Authentication(user_id, user_name) async def pass_login_credentials(self, step, credentials, cookies): auth_cookie = next(filter(lambda c: c['name'] == '_simpleauth_sess', cookies)) user_id, user_name = await self._api.authenticate(auth_cookie) self.store_credentials(auth_cookie) return Authentication(user_id, user_name) async def get_owned_games(self): self._getting_owned_games.set() self._owned_games = await self._library_resolver() self._getting_owned_games.clear() return [g.in_galaxy_format() for g in self._owned_games.values()] async def install_game(self, game_id): if game_id in self.__under_instalation: return self.__under_instalation.add(game_id) try: game = self._owned_games.get(game_id) if game is None: raise RuntimeError(f'Install game: game {game_id} not found. Owned games: {self._owned_games.keys()}') if isinstance(game, Key): args = [str(pathlib.Path(__file__).parent / 'keysgui.py'), game.human_name, game.key_type_human_name, str(game.key_val) ] process = await asyncio.create_subprocess_exec(sys.executable, *args, stderr=asyncio.subprocess.PIPE) _, stderr_data = await process.communicate() if stderr_data: logging.debug(args) logging.debug(stderr_data) return chosen_download = self._download_resolver(game) if isinstance(game, Subproduct): webbrowser.open(chosen_download.web) if isinstance(game, TroveGame): try: url = await self._api.get_trove_sign_url(chosen_download, game.machine_name) except AuthenticationRequired: logging.info('Looks like your Humble Monthly subscription has expired. Refer to config.ini to manage showed games.') webbrowser.open('https://www.humblebundle.com/monthly/subscriber') else: webbrowser.open(url['signed_url']) except Exception as e: report_problem(e, extra=game) logging.exception(e) finally: self.__under_instalation.remove(game_id) async def get_local_games(self): if not self._app_finder or not self._owned_games: return [] try: self._app_finder.refresh() except Exception as e: report_problem(e, None) return [] local_games = await self._app_finder.find_local_games(list(self._owned_games.values())) self._local_games.update({game.machine_name: game for game in local_games}) return [g.in_galaxy_format() for g in self._local_games.values()] async def launch_game(self, game_id): try: game = self._local_games[game_id] except KeyError as e: report_problem(e, {'local_games': self._local_games}) else: game.run() async def uninstall_game(self, game_id): try: game = self._local_games[game_id] except KeyError as e: report_problem(e, {'local_games': self._local_games}) else: game.uninstall() async def _check_owned(self): """ self.get_owned_games is called periodically by galaxy too rarely. This method check for new orders more often and also when relevant option in config file was changed. """ old_settings = astuple(self._settings.library) self._settings.reload_local_config_if_changed() if old_settings != astuple(self._settings.library): logging.info(f'Library settings has changed: {self._settings.library}') old_ids = self._owned_games.keys() self._owned_games = await self._library_resolver(only_cache=True) for old_id in old_ids - self._owned_games.keys(): self.remove_game(old_id) for new_id in self._owned_games.keys() - old_ids: self.add_game(self._owned_games[new_id].in_galaxy_format()) async def _check_statuses(self): """Check satuses of already found installed (local) games. Detects events when game is: - launched (via Galaxy for now) - stopped - uninstalled """ freezed_locals = list(self._local_games.values()) for game in freezed_locals: state = game.state if state == self._cached_game_states.get(game.id): continue self.update_local_game_status(LocalGame(game.id, state)) self._cached_game_states[game.id] = state await asyncio.sleep(0.5) async def _check_installed(self): """Searches for installed games and updates self._local_games""" await self.get_local_games() await asyncio.sleep(5) def tick(self): if self._check_owned_task.done() and not self._getting_owned_games.is_set(): self._check_owned_task = asyncio.create_task(self._check_owned()) if self._check_statuses_task.done(): self._check_statuses_task = asyncio.create_task(self._check_statuses()) if self._check_installed_task.done(): self._check_installed_task = asyncio.create_task(self._check_installed()) def shutdown(self): asyncio.create_task(self._api.close_session()) self._check_owned_task.cancel() self._check_installed_task.cancel() self._check_statuses_task.cancel()
def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._games = {} self._downloader = HumbleDownloader()
class HumbleBundlePlugin(Plugin): def __init__(self, reader, writer, token): super().__init__(Platform.HumbleBundle, __version__, reader, writer, token) self._api = AuthorizedHumbleAPI() self._games = {} self._downloader = HumbleDownloader() async def authenticate(self, stored_credentials=None): if not stored_credentials: return NextStep("web_session", AUTH_PARAMS) logging.info('stored credentials found') user_id, user_name = await self._api.authenticate(stored_credentials) return Authentication(user_id, user_name) async def pass_login_credentials(self, step, credentials, cookies): logging.debug(json.dumps(cookies, indent=2)) auth_cookie = next(filter(lambda c: c['name'] == '_simpleauth_sess', cookies)) user_id, user_name = await self._api.authenticate(auth_cookie) self.store_credentials(auth_cookie) return Authentication(user_id, user_name) async def get_owned_games(self): def is_game(sub): default = False return next(filter(lambda x: x['platform'] in GAME_PLATFORMS, sub['downloads']), default) games = {} gamekeys = await self._api.get_gamekeys() requests = [self._api.get_order_details(x) for x in gamekeys] logging.info(f'Fetching info about {len(requests)} orders started...') all_games_details = await asyncio.gather(*requests) logging.info('Fetching info finished') for details in all_games_details: for sub in details['subproducts']: try: if is_game(sub): games[sub['machine_name']] = HumbleGame(sub) except Exception as e: logging.error(f'Error while parsing subproduct {sub}: {repr(e)}') continue self._games = games return [g.in_galaxy_format() for g in games.values()] async def install_game(self, game_id): game = self._games.get(game_id) if game is None: logging.error(f'Install game: game {game_id} not found') return try: url = self._downloader.find_best_url(game.downloads) except Exception as e: logging.exception(e) else: webbrowser.open(url['web']) # async def launch_game(self, game_id): # pass # async def uninstall_game(self, game_id): # pass def shutdown(self): self._api._session.close()
def test_filename_from_web_link(): web_link = 'https://dl.humble.com/Almost_There_Windows.zip?gamekey=AbR9TcsD4ecueNGw&ttl=1587335864&t=a04a9b4f6512b7958f6357cb7b628452' expected = 'Almost_There_Windows.zip' assert expected == AuthorizedHumbleAPI._filename_from_web_link(web_link)