Ejemplo n.º 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()
Ejemplo n.º 2
0
class UplayPlugin(Plugin):
    def __init__(self, reader, writer, token):
        super().__init__(Platform.Uplay, __version__, reader, writer, token)
        self.client = BackendClient(self)
        self.local_client = LocalClient()
        self.cached_game_statuses = {}
        self.games_collection = GamesCollection()
        self.process_watcher = ProcessWatcher()
        self.game_status_notifier = GameStatusNotifier(self.process_watcher)
        self.tick_count = 0
        self.updating_games = False
        self.owned_games_sent = False
        self.parsing_club_games = False
        self.parsed_local_games = False

    def auth_lost(self):
        self.lost_authentication()

    async def authenticate(self, stored_credentials=None):
        if not stored_credentials:
            return NextStep("web_session", AUTH_PARAMS, cookies=COOKIES)
        else:
            try:
                user_data = await self.client.authorise_with_stored_credentials(
                    stored_credentials)
            except (AccessDenied, AuthenticationRequired) as e:
                log.exception(repr(e))
                raise InvalidCredentials()
            except Exception as e:
                log.exception(repr(e))
                raise e
            else:
                self.local_client.initialize(user_data['userId'])
                self.client.set_auth_lost_callback(self.auth_lost)
                return Authentication(user_data['userId'],
                                      user_data['username'])

    async def pass_login_credentials(self, step, credentials, cookies):
        """Called just after CEF authentication (called as NextStep by authenticate)"""
        user_data = await self.client.authorise_with_cookies(cookies)
        self.local_client.initialize(user_data['userId'])
        self.client.set_auth_lost_callback(self.auth_lost)
        return Authentication(user_data['userId'], user_data['username'])

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

        if SYSTEM == System.WINDOWS:
            self._parse_local_games()
            self._parse_local_game_ownership()

        await self._parse_club_games()
        try:
            await self._parse_subscription_games()
        except Exception as e:
            log.warning(
                f"Parsing subscriptions failed, most likely account without subscription {repr(e)}"
            )

        self.owned_games_sent = True

        for game in self.games_collection:
            game.considered_for_sending = True

        return [
            game.as_galaxy_game() for game in self.games_collection
            if game.owned
        ]

    async def _parse_subscription_games(self):
        subscription_games = []
        sub_response = await self.client.get_subscription()
        if not sub_response:
            return
        for game in sub_response['games']:
            subscription_games.append(
                UbisoftGame(space_id='',
                            launch_id=str(game['uplayGameId']),
                            install_id=str(game['uplayGameId']),
                            third_party_id='',
                            name=game['name'],
                            path='',
                            type=GameType.New,
                            special_registry_path='',
                            exe='',
                            status=GameStatus.Unknown,
                            owned=game['ownership'],
                            activation_id=str(game['id'])))
        self.games_collection.extend(subscription_games)

    async def _parse_club_games(self):
        if not self.parsing_club_games:
            try:
                self.parsing_club_games = True
                games = await self.client.get_club_titles()
                club_games = []
                for game in games:
                    if "platform" in game:
                        if game["platform"] == "PC":
                            log.info(
                                f"Parsed game from Club Request {game['title']}"
                            )
                            club_games.append(
                                UbisoftGame(space_id=game['spaceId'],
                                            launch_id='',
                                            install_id='',
                                            third_party_id='',
                                            name=game['title'],
                                            path='',
                                            type=GameType.New,
                                            special_registry_path='',
                                            exe='',
                                            status=GameStatus.Unknown,
                                            owned=True))
                        else:
                            log.debug(
                                f"Skipped game from Club Request for {game['platform']}: {game['spaceId']}, {game['title']}"
                            )

                self.games_collection.extend(club_games)
            except ApplicationError as e:
                log.error(
                    f"Encountered exception while parsing club games {repr(e)}"
                )
                raise e
            except Exception as e:
                log.error(
                    f"Encountered exception while parsing club games {repr(e)}"
                )
            finally:
                self.parsing_club_games = False
        else:
            # Wait until club games get parsed if parsing is already in progress
            while self.parsing_club_games:
                await asyncio.sleep(0.2)

    def _parse_local_games(self):
        """Parsing local files should lead to every game having a launch id.
        A game in the games_collection which doesn't have a launch id probably
        means that a game was added through the get_club_titles request but its space id
        was not present in configuration file and we couldn't find a matching launch id for it."""
        if self.local_client.configurations_accessible():
            try:
                configuration_data = self.local_client.read_config()
                p = LocalParser()
                games = []
                for game in p.parse_games(configuration_data):
                    games.append(game)
                self.games_collection.extend(games)
            except scanner.ScannerError as e:
                log.error(
                    f"Scanner error while parsing configuration, yaml is probably corrupted {repr(e)}"
                )

    def _parse_local_game_ownership(self):
        if self.local_client.ownership_accessible():
            ownership_data = self.local_client.read_ownership()
            p = LocalParser()
            ownership_records = p.get_owned_local_games(ownership_data)
            log.info(f"Ownership Records {ownership_records}")
            for game in self.games_collection:
                if game.install_id:
                    if int(game.install_id) in ownership_records:
                        game.owned = True
                if game.launch_id:
                    if int(game.launch_id) in ownership_records:
                        game.owned = True

    def _update_games(self):
        self.updating_games = True
        self._parse_local_games()
        self._parse_local_game_ownership()
        self.updating_games = False

    def _update_local_games_status(self):
        cached_statuses = self.cached_game_statuses
        if cached_statuses is None:
            return

        for game in self.games_collection:
            try:
                self.game_status_notifier.update_game(game)
                if game.status != cached_statuses[game.install_id]:
                    log.info(
                        f"Game {game.name} path changed: updating status from {cached_statuses[game.install_id]} to {game.status}"
                    )
                    self.update_local_game_status(game.as_local_game())
                    self.cached_game_statuses[game.install_id] = game.status
            except KeyError:
                self.game_status_notifier.update_game(game)
                ''' If a game wasn't previously in a cache then and it appears with an installed or running status
                 it most likely means that client was just installed '''
                if game.status in [GameStatus.Installed, GameStatus.Running]:
                    self.update_local_game_status(game.as_local_game())
                self.cached_game_statuses[game.install_id] = game.status

    if SYSTEM == System.WINDOWS:

        async def get_local_games(self):
            self._parse_local_games()

            local_games = []

            for game in self.games_collection:
                self.cached_game_statuses[game.launch_id] = game.status
                if game.status == GameStatus.Installed or game.status == GameStatus.Running:
                    local_games.append(game.as_local_game())
            self._update_local_games_status()
            self.parsed_local_games = True
            return local_games

    async def _add_new_games(self, games):
        await self._parse_club_games()
        self._parse_local_game_ownership()
        for game in games:
            if game.owned:
                self.add_game(game.as_galaxy_game())

    async def prepare_game_times_context(self, game_ids):
        return await self.get_playtime(game_ids)

    async def get_game_time(self, game_id, context):
        game_time = context.get(game_id)
        if game_time is None:
            raise UnknownError("Game {} not owned".format(game_id))
        return game_time

    async def get_playtime(self, game_ids):
        if not self.client.is_authenticated():
            raise AuthenticationRequired()

        games_playtime = {}
        blacklist = json.loads(
            self.persistent_cache.get('games_without_stats', '{}'))
        current_time = int(time.time())

        for game_id in game_ids:
            if not self.games_collection.get(game_id):
                await self.get_owned_games()
                break

        for game_id in game_ids:
            try:
                expire_in = blacklist.get(game_id, 0) - current_time
                if expire_in > 0:
                    log.debug(
                        f'Cache: No game stats for {game_id}. Recheck in {expire_in}s'
                    )
                    games_playtime[game_id] = GameTime(game_id, None, None)
                    continue

                game = self.games_collection[game_id]
                if not game.space_id:
                    games_playtime[game_id] = GameTime(game_id, None, None)
                    continue

                try:
                    response = await self.client.get_game_stats(game.space_id)
                except ApplicationError as err:
                    self._game_time_import_failure(game_id, err)
                    continue

                statscards = response.get('Statscards', None)
                if statscards is None:
                    blacklist[
                        game_id] = current_time + 3600 * 24 * 14  # two weeks
                    games_playtime[game_id] = GameTime(game_id, None, None)
                    continue

                playtime, last_played = find_times(statscards, game_id)
                if playtime == 0:
                    playtime = None
                if last_played == 0:
                    last_played = None
                log.info(
                    f'Stats for {game.name}: playtime: {playtime}, last_played: {last_played}'
                )
                games_playtime[game_id] = GameTime(game_id, playtime,
                                                   last_played)

            except Exception as e:
                log.error(
                    f"Getting game times for game {game_id} has crashed: " +
                    repr(e))
                self._game_time_import_failure(game_id, UnknownError())

        self.persistent_cache['games_without_stats'] = json.dumps(blacklist)
        self.push_cache()
        return games_playtime

    async def get_unlocked_challenges(self, game_id):
        """Challenges are a unique uplay club feature and don't directly translate to achievements"""
        if not self.client.is_authenticated():
            raise AuthenticationRequired()
        for game in self.games_collection:
            if game.space_id == game_id or game.launch_id == game_id:
                if not game.space_id:
                    return []
                challenges = await self.client.get_challenges(game.space_id)
                return [
                    Achievement(achievement_id=challenge["id"],
                                achievement_name=challenge["name"],
                                unlock_time=int(
                                    datetime.datetime.timestamp(
                                        dateutil.parser.parse(
                                            challenge["completionDate"]))))
                    for challenge in challenges["actions"]
                    if challenge["isCompleted"] and not challenge["isBadge"]
                ]

    if SYSTEM == System.WINDOWS:

        async def launch_game(self, game_id):
            if not self.parsed_local_games:
                await self.get_local_games()
            elif not self.user_can_perform_actions():
                return

            for game in self.games_collection.get_local_games():
                if (game.space_id == game_id or game.install_id == game_id
                        or game.launch_id
                        == game_id) and game.status == GameStatus.Installed:
                    if game.type == GameType.Steam:
                        if is_steam_installed():
                            url = f"start steam://rungameid/{game.third_party_id}"
                        else:
                            url = f"start uplay://open/game/{game.launch_id}"
                    elif game.type == GameType.New or game.type == GameType.Legacy:
                        log.debug('Launching game')
                        self.game_status_notifier._legacy_game_launched = True
                        url = f"start uplay://launch/{game.launch_id}"
                    else:
                        log.error(f"Unsupported game type {game.name}")
                        self.open_uplay_client()
                        return

                    log.info(
                        f"Launching game '{game.name}' by protocol: [{url}]")

                    subprocess.Popen(url, shell=True)
                    self.reset_tick_count()
                    return

            for game in self.games_collection:
                if (game.space_id == game_id
                        or game.install_id == game_id) and game.status in [
                            GameStatus.NotInstalled, GameStatus.Unknown
                        ]:
                    log.warning("Game is not installed, installing")
                    return await self.install_game(game_id)

            log.info("Failed to launch game, launching client instead.")
            self.open_uplay_client()

    async def activate_game(self, activation_id):
        if not await self.client.activate_game(activation_id):
            log.info(f"Couldnt activate game with id {activation_id}")
            return
        log.info(f"Activated game with id {activation_id}")
        timeout = time.time() + 3
        while timeout >= time.time():
            if self.local_client.ownership_changed():
                # Will refresh informations in collection about the game
                await self.get_owned_games()
            await asyncio.sleep(0.1)

    if SYSTEM == System.WINDOWS:

        async def install_game(self, game_id, retry=False):
            log.debug(self.games_collection)
            if not self.user_can_perform_actions():
                return

            for game in self.games_collection:
                game_ids = [game.space_id, game.install_id, game.launch_id]
                if (game_id in game_ids) and game.owned and game.status in [
                        GameStatus.NotInstalled, GameStatus.Unknown
                ]:
                    if game.install_id:
                        log.info(f"Installing game: {game_id}, {game}")
                        subprocess.Popen(
                            f"start uplay://install/{game.install_id}",
                            shell=True)
                        return
                if (game_id
                        in game_ids) and game.status == GameStatus.Installed:
                    log.warning("Game already installed, launching")
                    return await self.launch_game(game_id)

                if (game_id in game_ids
                    ) and not game.owned and game.activation_id and not retry:
                    log.warning("Activating game from subscription")
                    if not self.local_client.is_running():
                        self.open_uplay_client()
                        timeout = time.time() + 10
                        while not self.local_client.is_running(
                        ) and time.time() <= timeout:
                            await asyncio.sleep(0.1)
                    await self.activate_game(game.activation_id)
                    asyncio.create_task(
                        self.install_game(game_id=game_id, retry=True))

            # if launch_id is not known, try to launch local client instead
            self.open_uplay_client()
            log.info(
                f"Did not found game with game_id: {game_id}, proper launch_id and NotInstalled status, launching client."
            )

    if SYSTEM == System.WINDOWS:

        async def uninstall_game(self, game_id):
            if not self.user_can_perform_actions():
                return

            for game in self.games_collection.get_local_games():
                if (game.space_id == game_id or game.launch_id
                        == game_id) and game.status == GameStatus.Installed:
                    subprocess.Popen(
                        f"start uplay://uninstall/{game.launch_id}",
                        shell=True)
                    return

            self.open_uplay_client()
            log.info(
                f"Did not found game with game_id: {game_id}, proper launch_id and Installed status, launching client."
            )

    def user_can_perform_actions(self):
        if not self.local_client.is_installed:
            self.open_uplay_browser()
            return False
        if not self.local_client.was_user_logged_in:
            self.open_uplay_client()
            return False
        return True

    def open_uplay_client(self):
        subprocess.Popen("start uplay://", shell=True)

    def open_uplay_browser(self):
        url = 'https://uplay.ubisoft.com'
        log.info(f"Opening uplay website: {url}")
        webbrowser.open(url, autoraise=True)

    def refresh_game_statuses(self):
        if not self.local_client.was_user_logged_in:
            return
        statuses = self.game_status_notifier.statuses
        new_games = []
        for game in self.games_collection:
            try:
                if statuses[
                        game.
                        install_id] == GameStatus.Installed and game.status in [
                            GameStatus.NotInstalled, GameStatus.Unknown
                        ]:
                    log.info(
                        f"updating status for {game.name} to installed from not installed"
                    )
                    game.status = GameStatus.Installed
                    self.update_local_game_status(game.as_local_game())
                elif statuses[
                        game.
                        install_id] == GameStatus.Installed and game.status == GameStatus.Running:
                    log.info(
                        f"updating status for {game.name} to installed from running"
                    )
                    game.status = GameStatus.Installed
                    self.update_local_game_status(game.as_local_game())
                    asyncio.create_task(self.prevent_uplay_from_showing())
                elif statuses[
                        game.
                        install_id] == GameStatus.Running and game.status != GameStatus.Running:
                    log.info(f"updating status for {game.name} to running")
                    game.status = GameStatus.Running
                    self.update_local_game_status(game.as_local_game())
                elif statuses[game.install_id] in [
                        GameStatus.NotInstalled, GameStatus.Unknown
                ] and game.status not in [
                        GameStatus.NotInstalled, GameStatus.Unknown
                ]:
                    log.info(
                        f"updating status for {game.name} to not installed")
                    game.status = GameStatus.NotInstalled
                    self.update_local_game_status(game.as_local_game())
            except KeyError:
                continue

            if self.owned_games_sent and not game.considered_for_sending:
                game.considered_for_sending = True
                new_games.append(game)

        if new_games:
            asyncio.create_task(self._add_new_games(new_games))

    async def get_friends(self):
        friends = await self.client.get_friends()
        return [
            FriendInfo(user_id=friend["pid"],
                       user_name=friend["nameOnPlatform"])
            for friend in friends["friends"]
        ]

    async def get_subscriptions(self) -> List[Subscription]:
        sub_status = await self.client.get_subscription()
        sub_status = True if sub_status else False
        return [
            Subscription(
                subscription_name="Uplay+",
                end_time=None,
                owned=sub_status,
                subscription_discovery=SubscriptionDiscovery.AUTOMATIC)
        ]

    async def prepare_subscription_games_context(
            self, subscription_names: List[str]) -> Any:
        sub_games_response = await self.client.get_subscription()
        if sub_games_response:
            return [
                SubscriptionGame(game_title=game['name'],
                                 game_id=str(game['uplayGameId']))
                for game in sub_games_response["games"]
            ]
        return None

    async def get_subscription_games(
            self, subscription_name: str,
            context: Any) -> AsyncGenerator[List[SubscriptionGame], None]:
        yield context

    if SYSTEM == System.WINDOWS:

        async def launch_platform_client(self):
            if self.local_client.is_running():
                log.info(
                    "Launch platform client called but Uplay is already running"
                )
                return
            url = "start uplay://"
            subprocess.Popen(url, shell=True)
            # Uplay tries to get focus a couple of times when being launched
            end_time = time.time() + 15
            while time.time() <= end_time:
                await self.prevent_uplay_from_showing(kill_attempt=False)
                await asyncio.sleep(0.05)

    if SYSTEM == System.WINDOWS:

        async def shutdown_platform_client(self):
            if self.local_client.is_installed:
                subprocess.Popen("taskkill.exe /im \"upc.exe\"", shell=True)

    if SYSTEM == System.WINDOWS:

        async def prevent_uplay_from_showing(self, kill_attempt=True):
            if not self.local_client.is_installed:
                log.info("Local client not installed")
                return
            client_popup_wait_time = 5
            check_frequency_delay = 0.02

            end_time = time.time() + client_popup_wait_time
            hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay")
            while not ctypes.windll.user32.IsWindowVisible(hwnd):
                if time.time() >= end_time:
                    log.info("Timed out post close game uplay popup")
                    break
                hwnd = ctypes.windll.user32.FindWindowW(None, "Uplay")
                await asyncio.sleep(check_frequency_delay)
            if kill_attempt:
                await self.shutdown_platform_client()
            else:
                ctypes.windll.user32.SetForegroundWindow(hwnd)
                ctypes.windll.user32.CloseWindow(hwnd)

    if SYSTEM == System.WINDOWS:

        async def prepare_game_library_settings_context(self, game_ids):
            if self.local_client.settings_accessible():
                library_context = {}
                settings_data = self.local_client.read_settings()
                parser = LocalParser()
                favorite_games, hidden_games = parser.get_game_tags(
                    settings_data)
                for game_id in game_ids:
                    try:
                        game = self.games_collection[game_id]
                    except KeyError:
                        continue
                    library_context[game_id] = {
                        'favorite': game.launch_id in favorite_games,
                        'hidden': game.launch_id in hidden_games
                    }
                return library_context
            return None

        async def get_game_library_settings(self, game_id, context):
            log.debug(f"Context {context}")
            if not context:
                # Unable to retrieve context
                return GameLibrarySettings(game_id, None, None)
            game_library_settings = context.get(game_id)
            if game_library_settings is None:
                # Able to retrieve context but game is not in its values -> It doesnt have any tags or hidden status set
                return GameLibrarySettings(game_id, [], False)
            return GameLibrarySettings(
                game_id,
                ['favorite'] if game_library_settings['favorite'] else [],
                game_library_settings['hidden'])

    def reset_tick_count(self):
        # Resetting tick count ensures that certain operations performed on tick will be made with a known delay.
        self.tick_count = 0

    def tick(self):
        loop = asyncio.get_event_loop()
        if SYSTEM == System.WINDOWS:
            self.tick_count += 1
            if self.tick_count % 1 == 0:
                self.refresh_game_statuses()
            if self.tick_count % 5 == 0:
                self.game_status_notifier.launcher_log_path = self.local_client.launcher_log_path
            if self.tick_count % 9 == 0:
                self._update_local_games_status()
                if self.local_client.ownership_changed():
                    if not self.updating_games:
                        log.info(
                            'Ownership file has been changed or created. Reparsing.'
                        )
                        loop.run_in_executor(None, self._update_games)
        return

    async def shutdown(self):
        log.info("Plugin shutdown.")
        await self.client.close()
Ejemplo n.º 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()