예제 #1
0
class BNetPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)
        self.local_client = LocalClient(self._update_statuses)
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)

        self.watched_running_games = set()

    def handshake_complete(self):
        self.create_task(self.__delayed_handshake(), 'delayed handshake')

    async def __delayed_handshake(self):
        """
        Adds some minimal delay on Galaxy start before registering local data watchers.
        Apparently Galaxy may be not ready to receive notifications even after handshake_complete.
        """
        await asyncio.sleep(1)
        self.create_task(self.local_client.register_local_data_watcher(),
                         'local data watcher')
        self.create_task(self.local_client.register_classic_games_updater(),
                         'classic games updater')

    async def _notify_about_game_stop(self, game, starting_timeout):
        id_to_watch = game.info.uid

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(
                LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.has_galaxy_installed_state:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected not-fully installed game')
                    continue
            elif refr.has_galaxy_installed_state and not prev.has_galaxy_installed_state:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                self.create_task(self._notify_about_game_stop(refr, 5),
                                 'game stop waiter')
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None

    async def open_battlenet_browser(self):
        url = self.authentication_client.blizzard_battlenet_download_url
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(
            None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.local_client.get_installed_games().get(
            game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning(
                "Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in [classic.uid for classic in Blizzard.CLASSIC_GAMES]:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(
                f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}"
            )
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(
                f"Opening battlenet client on specific game_id {game_id} failed {e}"
            )
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.local_client.get_installed_games().get(
                    game_id, None)

                if installed_game is None or not os.access(
                        installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {game_id}')
                    self.update_local_game_status(
                        LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.local_client.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.local_client.config_parser.locale_language
                self.local_client.uninstaller.uninstall_game(
                    installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        try:
            game = self.local_client.get_installed_games().get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(
                    f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}'
                )
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(
                        os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(
                            f"{game.name} has no bundle id, help by providing us bundle id of this game"
                        )
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(
                    LocalGame(
                        game_id,
                        LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(
                LocalGame(game_id,
                          LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(
                    stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(
                        auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError,
                        UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(
                        auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info(
                    )
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info(
            )
            return self.authentication_client.parse_auth_after_setting_battletag(
            )

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(
            cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError,
                BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(
            auth_data.access_token)
        if not ("authorities" in auth_status
                and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info(
        )

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_battlenet_games(
                standard_games: dict,
                cn: bool) -> Dict[BlizzardGame, LicenseType]:
            licenses = {
                None: LicenseType.Unknown,
                "Trial": LicenseType.OtherUserLicense,
                "Good": LicenseType.SinglePurchase,
                "Inactive": LicenseType.SinglePurchase,
                "Banned": LicenseType.SinglePurchase,
                "Free": LicenseType.FreeToPlay
            }
            games = {}

            for standard_game in standard_games["gameAccounts"]:
                title_id = standard_game['titleId']
                try:
                    game = Blizzard.game_by_title_id(title_id, cn)
                except KeyError:
                    log.warning(
                        f"Skipping unknown game with titleId: {title_id}")
                else:
                    games[game] = licenses[standard_game.get(
                        "gameAccountStatus")]

            # Add wow classic if retail wow is present in owned games
            wow_license = games.get(Blizzard['wow'])
            if wow_license is not None:
                games[Blizzard['wow_classic']] = wow_license
            return games

        def _parse_classic_games(
                classic_games: dict) -> Dict[ClassicGame, LicenseType]:
            games = {}
            for classic_game in classic_games["classicGames"]:
                sanitized_name = classic_game["localizedGameName"].replace(
                    u'\xa0', ' ')
                for cg in Blizzard.CLASSIC_GAMES:
                    if cg.name == sanitized_name:
                        games[cg] = LicenseType.SinglePurchase
                        break
                else:
                    log.warning(
                        f"Skipping unknown classic game with name: {sanitized_name}"
                    )
            return games

        cn = self.authentication_client.region == 'cn'

        battlenet_games = _parse_battlenet_games(
            await self.backend_client.get_owned_games(), cn)
        classic_games = _parse_classic_games(
            await self.backend_client.get_owned_classic_games())
        owned_games: Dict[BlizzardGame, LicenseType] = {
            **battlenet_games,
            **classic_games
        }

        for game in Blizzard.try_for_free_games(cn):
            if game not in owned_games:
                owned_games[game] = LicenseType.FreeToPlay

        return [
            Game(game.uid, game.name, None, LicenseInfo(license_type))
            for game, license_type in owned_games.items()
        ]

    async def get_local_games(self):
        timeout = time.time() + 2

        try:
            translated_installed_games = []

            while not self.local_client.games_finished_parsing():
                await asyncio.sleep(0.1)
                if time.time() >= timeout:
                    break

            running_games = self.local_client.get_running_games()
            installed_games = self.local_client.get_installed_games()
            log.info(f"Installed games {installed_games.items()}")
            log.info(f"Running games {running_games}")
            for uid, game in installed_games.items():
                if game.has_galaxy_installed_state:
                    state = LocalGameState.Installed
                    if uid in running_games:
                        state |= LocalGameState.Running
                    translated_installed_games.append(LocalGame(uid, state))
            self.local_client.installed_games_cache = installed_games
            return translated_installed_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

    async def get_game_time(self, game_id, context):
        total_time = None
        last_played_time = None

        blizzard_game = Blizzard[game_id]

        if blizzard_game.name == "Overwatch":
            total_time = await self._get_overwatch_time()
            log.debug(f"Gametime for Overwatch is {total_time} minutes.")

        for config_info in self.local_client.config_parser.games:
            if config_info.uid == blizzard_game.uid:
                if config_info.last_played is not None:
                    last_played_time = int(config_info.last_played)
                break

        return GameTime(game_id, total_time, last_played_time)

    async def _get_overwatch_time(self) -> Union[None, int]:
        log.debug("Fetching playtime for Overwatch...")
        player_data = await self.backend_client.get_ow_player_data()
        if 'message' in player_data:  # user not found
            log.error('No Overwatch profile found.')
            return None
        if player_data['private'] == True:
            log.info('Unable to get data as Overwatch profile is private.')
            return None
        qp_time = player_data['playtime'].get('quickplay')
        if qp_time is None:  # user has not played quick play
            return 0
        if qp_time.count(':') == 1:  # minutes and seconds
            match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time)
            if match:
                return int(match.group('m'))
        elif qp_time.count(':') == 2:  # hours, minutes and seconds
            match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time)
            if match:
                return int(match.group('h')) * 60 + int(match.group('m'))
        raise UnknownBackendResponse(
            f'Unknown Overwatch API playtime format: {qp_time}')

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data(
            )
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(
                        character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(
                        data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )) for data in wow_character_data if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(
                            Achievement(achievement_id=ach[0],
                                        unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(
            self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
            account_data["regionId"], account_data["realmId"],
            account_data["profileId"])

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"],
                        unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def launch_platform_client(self):
        if self.local_client.is_running():
            log.info(
                "Launch platform client called but client is already running")
            return
        self.local_client.open_battlenet()
        await self.local_client.prevent_battlenet_from_showing()

    async def shutdown_platform_client(self):
        await self.local_client.shutdown_platform_client()

    async def shutdown(self):
        log.info("Plugin shutdown.")
        await self.authentication_client.shutdown()
예제 #2
0
class BNetPlugin(Plugin):
    PRODUCT_DB_PATH = pathlib.Path(AGENT_PATH) / 'product.db'
    CONFIG_PATH = CONFIG_PATH

    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)

        log.info(f"Starting Battle.net plugin, version {version}")

        self.bnet_client = None
        self.local_client = LocalClient()
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)
        self.social_features = SocialFeatures(self.authentication_client)
        self.error_state = False

        self.running_task = None

        self.database_parser = None
        self.config_parser = None
        self.uninstaller = None

        self.owned_games_cache = []

        self._classic_games_thread = None
        self._battlenet_games_thread = None
        self._installed_battlenet_games = {}
        self._installed_battlenet_games_lock = Lock()

        self.installed_games = self._parse_local_data()
        self.watched_running_games = set()

        self.notifications_enabled = False
        loop = asyncio.get_event_loop()
        loop.create_task(self._register_local_data_watcher())

    async def _register_local_data_watcher(self):
        async def ping(event, interval):
            while True:
                await asyncio.sleep(interval)
                if not self.watched_running_games:
                    if not event.is_set():
                        event.set()

        parse_local_data_event = asyncio.Event()
        FileWatcher(self.CONFIG_PATH, parse_local_data_event, interval=1)
        FileWatcher(self.PRODUCT_DB_PATH, parse_local_data_event, interval=2.5)
        asyncio.create_task(ping(parse_local_data_event, 30))
        while True:
            await parse_local_data_event.wait()
            refreshed_games = self._parse_local_data()
            if not self.notifications_enabled:
                self._update_statuses(refreshed_games, self.installed_games)
            self.installed_games = refreshed_games
            parse_local_data_event.clear()

    async def _notify_about_game_stop(self, game, starting_timeout):
        id_to_watch = game.info.id

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.playable:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected installation begin')
                    state = LocalGameState.None_
            elif refr.playable and not prev.playable:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                asyncio.create_task(self._notify_about_game_stop(refr, 5))
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def _load_local_files(self):
        try:
            product_db = load_product_db(self.PRODUCT_DB_PATH)
            self.database_parser = DatabaseParser(product_db)
        except FileNotFoundError as e:
            log.warning(f"product.db not found: {repr(e)}")
            return False
        except WindowsError as e:
            # 5 WindowsError access denied
            if e.winerror == 5:
                log.warning(f"product.db not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        except OSError as e:
            if e.errno == errno.EACCES:
                log.warning(f"product.db not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        else:
            if self.local_client.is_installed != self.database_parser.battlenet_present:
                self.local_client.refresh()

        try:
            config = load_config(self.CONFIG_PATH)
            self.config_parser = ConfigParser(config)
        except FileNotFoundError as e:
            log.warning(f"config file not found: {repr(e)}")
            self.config_parser = ConfigParser(None)
            return False
        except WindowsError as e:
            # 5 WindowsError access denied
            if e.winerror == 5:
                log.warning(f"config file not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        except OSError as e:
            if e.errno == errno.EACCES:
                log.warning(f"config file not accessible: {repr(e)}")
                self.config_parser = ConfigParser(None)
                return False
            else:
                raise ()
        return True

    def _get_battlenet_installed_games(self):

        def _add_battlenet_game(config_game, db_game):
            if config_game.uninstall_tag != db_game.uninstall_tag:
                return None
            try:
                blizzard_game = Blizzard[config_game.uid]
            except KeyError:
                log.warning(f'[{config_game.uid}] is not known blizzard game. Skipping')
                return None
            try:
                log.info(f"Adding {blizzard_game.blizzard_id} {blizzard_game.name} to installed games")
                return InstalledGame(
                    blizzard_game,
                    config_game.uninstall_tag,
                    db_game.version,
                    config_game.last_played,
                    db_game.install_path,
                    db_game.playable
                )
            except FileNotFoundError as e:
                log.warning(str(e) + '. Probably outdated product.db after uninstall. Skipping')
            return None

        games = {}
        for db_game in self.database_parser.games:
            for config_game in self.config_parser.games:
                installed_game = _add_battlenet_game(config_game, db_game)
                if installed_game:
                    games[installed_game.info.id] = installed_game
        self._installed_battlenet_games_lock.acquire()
        self._installed_battlenet_games = games
        self._installed_battlenet_games_lock.release()

    def _parse_local_data(self):
        """Game is considered as installed when present in both config and product.db"""
        games = {}
        # give threads 4 seconds to finish
        join_timeout = 4

        if not self._classic_games_thread or not self._classic_games_thread.isAlive():
            self._classic_games_thread = Thread(target=self.local_client.find_classic_games, daemon=True)
            self._classic_games_thread.start()
            log.info("Started classic games thread")

        if not self._load_local_files():
            self._classic_games_thread.join(join_timeout)
            if not self.local_client.classics_lock.acquire(False):
                return []
            else:
                installed_classics = self.local_client.installed_classics
                self.local_client.classics_lock.release()
                return installed_classics

        try:
            if SYSTEM == pf.WINDOWS and self.uninstaller is None:
                uninstaller_path = pathlib.Path(AGENT_PATH) / 'Blizzard Uninstaller.exe'
                self.uninstaller = Uninstaller(uninstaller_path)
        except FileNotFoundError as e:
            log.warning('uninstaller not found' + str(e))

        try:
            if self.local_client.is_installed != self.database_parser.battlenet_present:
                self.local_client.refresh()
            log.info(f"Games found in db {self.database_parser.games}")
            log.info(f"Games found in config {self.config_parser.games}")

            if not self._battlenet_games_thread or not self._battlenet_games_thread.isAlive():
                self._battlenet_games_thread = Thread(target=self._get_battlenet_installed_games, daemon=True)
                self._battlenet_games_thread.start()
                log.info("Started classic games thread")
        except Exception as e:
            log.exception(str(e))
        finally:
            self._classic_games_thread.join(join_timeout)
            self._battlenet_games_thread.join(join_timeout)

            if self.local_client.classics_lock.acquire(False):
                games = self.local_client.installed_classics
                self.local_client.classics_lock.release()

            if self._installed_battlenet_games_lock.acquire(False):
                games = {**self._installed_battlenet_games, **games}
                self._installed_battlenet_games_lock.release()

            return games

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None
        self.owned_games_cache = []

    async def open_battlenet_browser(self):
        url = f"https://www.blizzard.com/apps/battle.net/desktop"
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.installed_games.get(game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning("Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in Blizzard.legacy_game_ids:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}")
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(f"Opening battlenet client on specific game_id {game_id} failed {e}")
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.installed_games.get(game_id, None)

                if installed_game is None or not os.access(installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {Blizzard[game_id].uid}')
                    self.update_local_game_status(LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.config_parser.locale_language
                self.uninstaller.uninstall_game(installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        try:
            if self.installed_games is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            game = self.installed_games.get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}')
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(f"{game.name} has no bundle id, help by providing us bundle id of this game")
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(LocalGame(game_id, LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info()
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info()
            return self.authentication_client.parse_auth_after_setting_battletag()

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError, BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(auth_data.access_token)
        if not ("authorities" in auth_status and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info()

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_friends(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()
        friends_list = await self.social_features.get_friends()
        return [FriendInfo(user_id=friend.id.low, user_name='') for friend in friends_list]

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_classic_games(classic_games):
            for classic_game in classic_games["classicGames"]:
                log.info(f"looking for {classic_game} in classic games")
                try:
                    blizzard_game = Blizzard[classic_game["localizedGameName"].replace(u'\xa0', ' ')]
                    log.info(f"match! {blizzard_game}")
                    classic_game["titleId"] = blizzard_game.uid
                    classic_game["gameAccountStatus"] = "Good"
                except KeyError:
                    continue

            return classic_games

        def _get_not_added_free_games(owned_games):
            owned_games_ids = []
            for game in owned_games:
                if "titleId" in game:
                    owned_games_ids.append(str(game["titleId"]))

            return [{"titleId": game.blizzard_id,
                    "localizedGameName": game.name,
                    "gameAccountStatus": "Free"}
                    for game in Blizzard.free_games if game.blizzard_id not in owned_games_ids]

        try:
            games = await self.backend_client.get_owned_games()
            classic_games = _parse_classic_games(await self.backend_client.get_owned_classic_games())
            owned_games = games["gameAccounts"] + classic_games["classicGames"]

            # Add wow classic if retail wow is present in owned games
            for owned_game in owned_games.copy():
                if 'titleId' in owned_game:
                    if owned_game['titleId'] == 5730135:
                        owned_games.append({'titleId': 'wow_classic',
                                            'localizedGameName': 'World of Warcraft Classic',
                                            'gameAccountStatus': owned_game['gameAccountStatus']})

            free_games_to_add = _get_not_added_free_games(owned_games)
            owned_games += free_games_to_add
            log.info(f"Owned games {owned_games} with free games")
            self.owned_games_cache = owned_games
            return [
                Game(
                    str(game["titleId"]),
                    game["localizedGameName"],
                    [],
                    LicenseInfo(License_Map[game["gameAccountStatus"]]),
                )
                for game in self.owned_games_cache if "titleId" in game
            ]
        except Exception as e:
            log.exception(f"failed to get owned games: {repr(e)}")
            raise

    async def get_local_games(self):

        try:
            local_games = []
            running_games = ProcessProvider().update_games_processes(self.installed_games.values())
            log.info(f"Installed games {self.installed_games.items()}")
            log.info(f"Running games {running_games}")
            for id_, game in self.installed_games.items():
                if game.playable:
                    state = LocalGameState.Installed
                    if id_ in running_games:
                        state |= LocalGameState.Running
                else:
                    state = LocalGameState.None_
                local_games.append(LocalGame(id_, state))

            return local_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

        finally:
            self.enable_notifications = True

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data()
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )
                )
                for data in wow_character_data
                if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(Achievement(achievement_id=ach[0], unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
                                                         account_data["regionId"], account_data["realmId"],
                                                         account_data["profileId"]
                                                         )

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"], unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def _tick_runner(self):
        if not self.bnet_client:
            return
        try:
            self.error_state = await self.bnet_client.tick()
        except Exception as e:
            self.error_state = True
            log.exception(f"error state: {str(e)}")
            raise

    def tick(self):
        if not self.error_state and (not self.running_task or self.running_task.done()):
            self.running_task = asyncio.create_task(self._tick_runner())
        elif self.error_state:
            sys.exit(1)

    def shutdown(self):
        log.info("Plugin shutdown.")
        asyncio.create_task(self.authentication_client.shutdown())
예제 #3
0
class BNetPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Battlenet, version, reader, writer, token)
        self.local_client = LocalClient(self._update_statuses)
        self.authentication_client = AuthenticatedHttpClient(self)
        self.backend_client = BackendClient(self, self.authentication_client)

        self.owned_games_cache = []
        self.watched_running_games = set()
        self.local_games_called = False

    async def _notify_about_game_stop(self, game, starting_timeout):
        if not self.local_games_called:
            return
        id_to_watch = game.info.id

        if id_to_watch in self.watched_running_games:
            log.debug(f'Game {id_to_watch} is already watched. Skipping')
            return

        try:
            self.watched_running_games.add(id_to_watch)
            await asyncio.sleep(starting_timeout)
            ProcessProvider().update_games_processes([game])
            log.info(f'Setuping process watcher for {game._processes}')
            loop = asyncio.get_event_loop()
            await loop.run_in_executor(None, game.wait_until_game_stops)
        finally:
            self.update_local_game_status(
                LocalGame(id_to_watch, LocalGameState.Installed))
            self.watched_running_games.remove(id_to_watch)

    def _update_statuses(self, refreshed_games, previous_games):
        if not self.local_games_called:
            return
        for blizz_id, refr in refreshed_games.items():
            prev = previous_games.get(blizz_id, None)

            if prev is None:
                if refr.playable:
                    log.debug('Detected playable game')
                    state = LocalGameState.Installed
                else:
                    log.debug('Detected installation begin')
                    state = LocalGameState.None_
            elif refr.playable and not prev.playable:
                log.debug('Detected playable game')
                state = LocalGameState.Installed
            elif refr.last_played != prev.last_played:
                log.debug('Detected launched game')
                state = LocalGameState.Installed | LocalGameState.Running
                asyncio.create_task(self._notify_about_game_stop(refr, 5))
            else:
                continue

            log.info(f'Changing game {blizz_id} state to {state}')
            self.update_local_game_status(LocalGame(blizz_id, state))

        for blizz_id, prev in previous_games.items():
            refr = refreshed_games.get(blizz_id, None)
            if refr is None:
                log.debug('Detected uninstalled game')
                state = LocalGameState.None_
                self.update_local_game_status(LocalGame(blizz_id, state))

    def log_out(self):
        if self.backend_client:
            asyncio.create_task(self.authentication_client.shutdown())
        self.authentication_client.user_details = None
        self.owned_games_cache = []

    async def open_battlenet_browser(self):
        url = self.authentication_client.blizzard_battlenet_download_url
        log.info(f'Opening battle.net website: {url}')
        loop = asyncio.get_running_loop()
        await loop.run_in_executor(
            None, lambda x: webbrowser.open(x, autoraise=True), url)

    async def install_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        installed_game = self.local_client.get_installed_games().get(
            game_id, None)
        if installed_game and os.access(installed_game.install_path, os.F_OK):
            log.warning(
                "Received install command on an already installed game")
            return await self.launch_game(game_id)

        if game_id in Blizzard.legacy_game_ids:
            if SYSTEM == pf.WINDOWS:
                platform = 'windows'
            elif SYSTEM == pf.MACOS:
                platform = 'macos'
            webbrowser.open(
                f"https://www.blizzard.com/download/confirmation?platform={platform}&locale=enUS&version=LIVE&id={game_id}"
            )
            return
        try:
            self.local_client.refresh()
            log.info(f'Installing game of id {game_id}')
            self.local_client.install_game(game_id)
        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except Exception as e:
            log.exception(f"Installing game {game_id} failed: {e}")

    def _open_battlenet_at_id(self, game_id):
        try:
            self.local_client.refresh()
            self.local_client.open_battlenet(game_id)
        except Exception as e:
            log.exception(
                f"Opening battlenet client on specific game_id {game_id} failed {e}"
            )
            try:
                self.local_client.open_battlenet()
            except Exception as e:
                log.exception(f"Opening battlenet client failed {e}")

    async def uninstall_game(self, game_id):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        if game_id == 'wow_classic':
            # attempting to uninstall classic wow through protocol gives you a message that the game cannot
            # be uninstalled through protocol and you should use battle.net
            return self._open_battlenet_at_id(game_id)

        if SYSTEM == pf.MACOS:
            self._open_battlenet_at_id(game_id)
        else:
            try:
                installed_game = self.local_client.get_installed_games().get(
                    game_id, None)

                if installed_game is None or not os.access(
                        installed_game.install_path, os.F_OK):
                    log.error(f'Cannot uninstall {Blizzard[game_id].uid}')
                    self.update_local_game_status(
                        LocalGame(game_id, LocalGameState.None_))
                    return

                if not isinstance(installed_game.info, ClassicGame):
                    if self.local_client.uninstaller is None:
                        raise FileNotFoundError('Uninstaller not found')

                uninstall_tag = installed_game.uninstall_tag
                client_lang = self.local_client.config_parser.locale_language
                self.local_client.uninstaller.uninstall_game(
                    installed_game, uninstall_tag, client_lang)

            except Exception as e:
                log.exception(f'Uninstalling game {game_id} failed: {e}')

    async def launch_game(self, game_id):
        if not self.local_games_called:
            await self.get_local_games()

        try:
            if self.local_client.get_installed_games() is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            game = self.local_client.get_installed_games().get(game_id, None)
            if game is None:
                log.error(f'Launching game that is not installed: {game_id}')
                return await self.install_game(game_id)

            if isinstance(game.info, ClassicGame):
                log.info(
                    f'Launching game of id: {game_id}, {game} at path {os.path.join(game.install_path, game.info.exe)}'
                )
                if SYSTEM == pf.WINDOWS:
                    subprocess.Popen(
                        os.path.join(game.install_path, game.info.exe))
                elif SYSTEM == pf.MACOS:
                    if not game.info.bundle_id:
                        log.warning(
                            f"{game.name} has no bundle id, help by providing us bundle id of this game"
                        )
                    subprocess.Popen(['open', '-b', game.info.bundle_id])

                self.update_local_game_status(
                    LocalGame(
                        game_id,
                        LocalGameState.Installed | LocalGameState.Running))
                asyncio.create_task(self._notify_about_game_stop(game, 6))
                return

            self.local_client.refresh()
            log.info(f'Launching game of id: {game_id}, {game}')
            await self.local_client.launch_game(game, wait_sec=60)

            self.update_local_game_status(
                LocalGame(game_id,
                          LocalGameState.Installed | LocalGameState.Running))
            self.local_client.close_window()
            asyncio.create_task(self._notify_about_game_stop(game, 3))

        except ClientNotInstalledError as e:
            log.warning(e)
            await self.open_battlenet_browser()
        except TimeoutError as e:
            log.warning(str(e))
        except Exception as e:
            log.exception(f"Launching game {game_id} failed: {e}")

    async def authenticate(self, stored_credentials=None):
        try:
            if stored_credentials:
                auth_data = self.authentication_client.process_stored_credentials(
                    stored_credentials)
                try:
                    await self.authentication_client.create_session()
                    await self.backend_client.refresh_cookies()
                    auth_status = await self.backend_client.validate_access_token(
                        auth_data.access_token)
                except (BackendNotAvailable, BackendError, NetworkError,
                        UnknownError, BackendTimeout) as e:
                    raise e
                except Exception:
                    raise InvalidCredentials()
                if self.authentication_client.validate_auth_status(
                        auth_status):
                    self.authentication_client.user_details = await self.backend_client.get_user_info(
                    )
                return self.authentication_client.parse_user_details()
            else:
                return self.authentication_client.authenticate_using_login()
        except Exception as e:
            raise e

    async def pass_login_credentials(self, step, credentials, cookies):
        if "logout&app=oauth" in credentials['end_uri']:
            # 2fa expired, repeat authentication
            return self.authentication_client.authenticate_using_login()

        if self.authentication_client.attempted_to_set_battle_tag:
            self.authentication_client.user_details = await self.backend_client.get_user_info(
            )
            return self.authentication_client.parse_auth_after_setting_battletag(
            )

        cookie_jar = self.authentication_client.parse_cookies(cookies)
        auth_data = await self.authentication_client.get_auth_data_login(
            cookie_jar, credentials)

        try:
            await self.authentication_client.create_session()
            await self.backend_client.refresh_cookies()
        except (BackendNotAvailable, BackendError, NetworkError, UnknownError,
                BackendTimeout) as e:
            raise e
        except Exception:
            raise InvalidCredentials()

        auth_status = await self.backend_client.validate_access_token(
            auth_data.access_token)
        if not ("authorities" in auth_status
                and "IS_AUTHENTICATED_FULLY" in auth_status["authorities"]):
            raise InvalidCredentials()

        self.authentication_client.user_details = await self.backend_client.get_user_info(
        )

        self.authentication_client.set_credentials()

        return self.authentication_client.parse_battletag()

    async def get_owned_games(self):
        if not self.authentication_client.is_authenticated():
            raise AuthenticationRequired()

        def _parse_classic_games(classic_games):
            for classic_game in classic_games["classicGames"]:
                log.info(f"looking for {classic_game} in classic games")
                try:
                    blizzard_game = Blizzard[
                        classic_game["localizedGameName"].replace(
                            u'\xa0', ' ')]
                    log.info(f"match! {blizzard_game}")
                    classic_game["titleId"] = blizzard_game.uid
                    classic_game["gameAccountStatus"] = "Good"
                except KeyError:
                    continue

            return classic_games

        def _get_not_added_free_games(owned_games):
            owned_games_ids = []
            for game in owned_games:
                if "titleId" in game:
                    owned_games_ids.append(str(game["titleId"]))

            return [{
                "titleId": game.blizzard_id,
                "localizedGameName": game.name,
                "gameAccountStatus": "Free"
            } for game in Blizzard.free_games
                    if game.blizzard_id not in owned_games_ids]

        try:
            games = await self.backend_client.get_owned_games()
            classic_games = _parse_classic_games(
                await self.backend_client.get_owned_classic_games())
            owned_games = games["gameAccounts"] + classic_games["classicGames"]

            # Add wow classic if retail wow is present in owned games
            for owned_game in owned_games.copy():
                if 'titleId' in owned_game:
                    if owned_game['titleId'] == 5730135:
                        owned_games.append({
                            'titleId':
                            'wow_classic',
                            'localizedGameName':
                            'World of Warcraft Classic',
                            'gameAccountStatus':
                            owned_game['gameAccountStatus']
                        })

            free_games_to_add = _get_not_added_free_games(owned_games)
            owned_games += free_games_to_add
            self.owned_games_cache = owned_games
            return [
                Game(
                    str(game["titleId"]),
                    game["localizedGameName"],
                    [],
                    LicenseInfo(License_Map[game["gameAccountStatus"]]),
                ) for game in self.owned_games_cache if "titleId" in game
            ]
        except Exception as e:
            log.exception(f"failed to get owned games: {repr(e)}")
            raise

    async def get_local_games(self):
        timeout = time.time() + 2

        try:
            translated_installed_games = []

            while not self.local_client.games_finished_parsing():
                await asyncio.sleep(0.1)
                if time.time() >= timeout:
                    break

            running_games = self.local_client.get_running_games()
            installed_games = self.local_client.get_installed_games()
            log.info(f"Installed games {installed_games.items()}")
            log.info(f"Running games {running_games}")
            for id_, game in installed_games.items():
                if game.playable:
                    state = LocalGameState.Installed
                    if id_ in running_games:
                        state |= LocalGameState.Running
                else:
                    state = LocalGameState.None_
                translated_installed_games.append(LocalGame(id_, state))
            self.local_client.installed_games_cache = installed_games
            return translated_installed_games

        except Exception as e:
            log.exception(f"failed to get local games: {str(e)}")
            raise

        finally:
            self.local_games_called = True

    async def get_game_time(self, game_id, context):
        game_time_minutes = None
        if game_id == "5272175":
            game_time_minutes = await self._get_overwatch_time()
            log.debug(
                f"Gametime for Overwatch is {game_time_minutes} minutes.")
        return GameTime(game_id, game_time_minutes, None)

    async def _get_overwatch_time(self) -> Union[None, int]:
        log.debug("Fetching playtime for Overwatch...")
        player_data = await self.backend_client.get_ow_player_data()
        if 'message' in player_data:  # user not found... unfortunately no 404 status code is returned :/
            log.error('No Overwatch profile found.')
            return None
        if player_data['private'] == True:
            log.info('Unable to get data as Overwatch profile is private.')
            return None
        qp_time = player_data['playtime']['quickplay']
        if qp_time is None:  # user has not played quick play
            return 0
        if qp_time.count(':') == 1:  # minutes and seconds
            match = re.search('(?:(?P<m>\\d+):)(?P<s>\\d+)', qp_time)
            if match:
                return int(match.group('m'))
        elif qp_time.count(':') == 2:  # hours, minutes and seconds
            match = re.search('(?:(?P<h>\\d+):)(?P<m>\\d+)', qp_time)
            if match:
                return int(match.group('h')) * 60 + int(match.group('m'))
        raise UnknownBackendResponse(
            f'Unknown Overwatch API playtime format: {qp_time}')

    async def _get_wow_achievements(self):
        achievements = []
        try:
            characters_data = await self.backend_client.get_wow_character_data(
            )
            characters_data = characters_data["characters"]

            wow_character_data = await asyncio.gather(
                *[
                    self.backend_client.get_wow_character_achievements(
                        character["realm"], character["name"])
                    for character in characters_data
                ],
                return_exceptions=True,
            )

            for data in wow_character_data:
                if isinstance(data, requests.Timeout) or isinstance(
                        data, requests.ConnectionError):
                    raise data

            wow_achievement_data = [
                list(
                    zip(
                        data["achievements"]["achievementsCompleted"],
                        data["achievements"]["achievementsCompletedTimestamp"],
                    )) for data in wow_character_data if type(data) is dict
            ]

            already_in = set()

            for char_ach in wow_achievement_data:
                for ach in char_ach:
                    if ach[0] not in already_in:
                        achievements.append(
                            Achievement(achievement_id=ach[0],
                                        unlock_time=int(ach[1] / 1000)))
                        already_in.add(ach[0])
        except (AccessTokenExpired, BackendError) as e:
            log.exception(str(e))
        with open('wow.json', 'w') as f:
            f.write(json.dumps(achievements, cls=DataclassJSONEncoder))
        return achievements

    async def _get_sc2_achievements(self):
        account_data = await self.backend_client.get_sc2_player_data(
            self.authentication_client.user_details["id"])

        # TODO what if more sc2 accounts?
        assert len(account_data) == 1
        account_data = account_data[0]

        profile_data = await self.backend_client.get_sc2_profile_data(
            account_data["regionId"], account_data["realmId"],
            account_data["profileId"])

        sc2_achievement_data = [
            Achievement(achievement_id=achievement["achievementId"],
                        unlock_time=achievement["completionDate"])
            for achievement in profile_data["earnedAchievements"]
            if achievement["isComplete"]
        ]

        with open('sc2.json', 'w') as f:
            f.write(json.dumps(sc2_achievement_data, cls=DataclassJSONEncoder))
        return sc2_achievement_data

    # async def get_unlocked_achievements(self, game_id):
    #     if not self.website_client.is_authenticated():
    #         raise AuthenticationRequired()
    #     try:
    #         if game_id == "21298":
    #             return await self._get_sc2_achievements()
    #         elif game_id == "5730135":
    #             return await self._get_wow_achievements()
    #         else:
    #             return []
    #     except requests.Timeout:
    #         raise BackendTimeout()
    #     except requests.ConnectionError:
    #         raise NetworkError()
    #     except Exception as e:
    #         log.exception(str(e))
    #         return []

    async def launch_platform_client(self):
        if self.local_client.is_running():
            log.info(
                "Launch platform client called but client is already running")
            return
        self.local_client.open_battlenet()
        await self.local_client.prevent_battlenet_from_showing()

    async def shutdown_platform_client(self):
        await self.local_client.shutdown_platform_client()

    async def shutdown(self):
        log.info("Plugin shutdown.")
        await self.authentication_client.shutdown()