import time import logging import asyncio from typing import Callable, Dict, List, Set, Iterable, Any, Coroutine from consts import SOURCE, NON_GAME_BUNDLE_TYPES from model.product import Product from model.game import HumbleGame, Subproduct, Key, KeyGame from model.types import GAME_PLATFORMS from settings import LibrarySettings logger = logging.getLogger(__name__) class LibraryResolver: NEXT_FETCH_IN = 3600 * 24 * 14 def __init__(self, api, settings: LibrarySettings, save_cache_callback: Callable, cache: Dict[str, list]): self._api = api self._save_cache = save_cache_callback self._settings = settings self._cache = cache async def __call__(self, only_cache: bool = False) -> Dict[str, HumbleGame]: if not only_cache: await self._fetch_and_update_cache() # get all games in predefined order orders = list(self._cache.get('orders', {}).values()) # type: ignore[union-attr] - orders is always a dict all_games: List[HumbleGame] = [] for source in self._settings.sources: if source == SOURCE.DRM_FREE: all_games.extend(self._get_subproducts(orders)) elif source == SOURCE.KEYS: all_games.extend(self._get_keys(orders, self._settings.show_revealed_keys)) logger.info(f'all_games: {all_games}') # deduplication of the games with the same title deduplicated: Dict[str, HumbleGame] = {} titles: Set[str] = set() for game in all_games: if game.human_name not in titles: titles.add(game.human_name) deduplicated[game.machine_name] = game return deduplicated async def _fetch_and_update_cache(self): sources = self._settings.sources if SOURCE.DRM_FREE in sources or SOURCE.KEYS in sources: next_fetch_orders = self._cache.get('next_fetch_orders') if next_fetch_orders is None or time.time() > next_fetch_orders: logger.info('Refreshing all orders') self._cache['orders'] = await self._fetch_orders([]) self._cache['next_fetch_orders'] = time.time() + self.NEXT_FETCH_IN else: const_orders = { gamekey: order for gamekey, order in self._cache.get('orders', {}).items() if self.__is_const(order) } self._cache.setdefault('orders', {}).update(await self._fetch_orders(const_orders)) self._save_cache(self._cache) async def _fetch_orders(self, cached_gamekeys: Iterable[str]) -> Dict[str, dict]: gamekeys = await self._api.get_gamekeys() order_tasks = [self._api.get_order_details(x) for x in gamekeys if x not in cached_gamekeys] orders = await self.__gather_no_exceptions(order_tasks) orders = self.__filter_out_not_game_bundles(orders) return {order['gamekey']: order for order in orders} @staticmethod async def __gather_no_exceptions(tasks: Iterable[Coroutine]): """Wrapper around asyncio.gather(*args, return_exception=True) Returns list of non-exception items. If every item is exception, raise first of them, else logs them. Use case: https://github.com/UncleGoogle/galaxy-integration-humblebundle/issues/59 """ items = await asyncio.gather(*tasks, return_exceptions=True) if len(items) == 0: return [] err: List[Exception] = [] ok: List[Any] = [] for it in items: (err if isinstance(it, Exception) else ok).append(it) if len(ok) == 0: raise err[0] if err and len(err) != len(items): logger.error(f'Exception(s) occured: [{err}].\nSkipping and going forward') return ok @staticmethod def __is_const(order): """Tells if this order can be safly cached or may change its content in the future""" if 'choices_remaining' in order and order['choices_remaining'] != 0: return False for key in order['tpkd_dict']['all_tpks']: if 'redeemed_key_val' not in key: return False return True @staticmethod def __filter_out_not_game_bundles(orders: list) -> list: filtered = [] for details in orders: product = Product(details['product']) if product.bundle_type in NON_GAME_BUNDLE_TYPES: logger.info(f'Ignoring {details["product"]["machine_name"]} due bundle type: {product.bundle_type}') continue filtered.append(details) return filtered @staticmethod def _get_subproducts(orders: list) -> List[Subproduct]: subproducts = [] for details in orders: for sub_data in details['subproducts']: sub = Subproduct(sub_data) try: sub.in_galaxy_format() # minimal validation except Exception as e: logger.warning(f"Error while parsing subproduct {repr(e)}: {sub_data}", extra={'data': sub_data}) continue if not set(sub.downloads).isdisjoint(GAME_PLATFORMS): # at least one download exists for supported OS subproducts.append(sub) return subproducts @staticmethod def _get_keys(orders: list, show_revealed_keys: bool) -> List[KeyGame]: keys = [] for details in orders: for tpks in details['tpkd_dict']['all_tpks']: key = Key(tpks) try: key.in_galaxy_format() # minimal validation except Exception as e: logger.warning(f"Error while parsing tpks {repr(e)}: {tpks}", extra={'tpks': tpks}) else: if key.key_val is None or show_revealed_keys: keys.extend(key.key_games) return keys