diff --git a/assets/versions.json b/assets/versions.json index fa4bef4d0..d8beaa89f 100755 --- a/assets/versions.json +++ b/assets/versions.json @@ -5,6 +5,7 @@ "game_tracker" ], "supported_providers": [ - "StartGG" + "StartGG", + "ParryGG" ] } \ No newline at end of file diff --git a/dependencies/requirements.txt b/dependencies/requirements.txt old mode 100755 new mode 100644 index 84229cb9b..e8bd17ad2 --- a/dependencies/requirements.txt +++ b/dependencies/requirements.txt @@ -24,3 +24,6 @@ tqdm==2.2.3 tzdata zstd==1.5.7.3 countryflag==1.1.1 +grpcio +parrygg>=0.1.4 +python-dotenv diff --git a/src/Settings/TSHSettingsWindow.py b/src/Settings/TSHSettingsWindow.py index 4b0c9338d..919d5cb91 100644 --- a/src/Settings/TSHSettingsWindow.py +++ b/src/Settings/TSHSettingsWindow.py @@ -314,6 +314,24 @@ def UiMounted(self): self.add_setting_widget(QApplication.translate( "settings", "Bluesky"), SettingsWidget("bsky_account", bskySettings)) + + # Add API Key settings + APIKeySettings = [] + APIKeySettings.append(( + QApplication.translate( + "settings.api_keys", "parry.gg"), + "parrygg", + "password", + "", + None, + QApplication.translate( + "settings.api_keys", "You can get an API Key from parry.gg/api-keys") + "\n" + + QApplication.translate( + "settings.api_keys", "Please note that the API Key will be stored in plain text on your computer") + )) + + self.add_setting_widget(QApplication.translate( + "settings", "API Keys"), SettingsWidget("api_keys", APIKeySettings)) self.resize(1000, 500) QApplication.processEvents() diff --git a/src/TSHGameAssetManager.py b/src/TSHGameAssetManager.py index 960469379..e1461965f 100644 --- a/src/TSHGameAssetManager.py +++ b/src/TSHGameAssetManager.py @@ -173,6 +173,27 @@ def detect_smashgg_id_match(game, id): if detect_smashgg_id_match(game, gameid): self.LoadGameAssets(i+1) break + + def SetGameFromParryGGId(self, gameid): + def detect_parrygg_id_match(game, id): + result = str(game.get("parrygg_game_slug", "")) == str(id) + if not result: + alternates = game.get("alternate_versions", []) + alternates_ids = [] + for alternate in alternates: + if alternate.get("parrygg_game_slug"): + alternates_ids.append( + str(alternate.get("parrygg_game_slug"))) + result = str(id) in alternates_ids + return (result) + + if len(self.games.keys()) == 0: + return + + for i, game in enumerate(self.games.values()): + if detect_parrygg_id_match(game, gameid): + self.LoadGameAssets(i+1) + break def CopyCSS(self, game): # Make dir if doesn't exists diff --git a/src/TSHScoreboardWidget.py b/src/TSHScoreboardWidget.py index 472ca87e7..940c8d985 100644 --- a/src/TSHScoreboardWidget.py +++ b/src/TSHScoreboardWidget.py @@ -681,12 +681,17 @@ def UpdateBottomButtons(self): self.btSelectSet.setText( QApplication.translate("app", "Load set from {0}").format(TSHTournamentDataProvider.instance.provider.url)) self.btSelectSet.setEnabled(True) - if self.scoreboardNumber <= 1 and not SettingsManager.Get("general.hide_track_player", False): - self.btLoadPlayerSet.setEnabled(True) + self.btLoadStationSet.setEnabled(True) + if TSHTournamentDataProvider.instance.provider.name == "StartGG": + if self.scoreboardNumber <= 1 and not SettingsManager.Get("general.hide_track_player", False): + self.btLoadPlayerSet.setEnabled(True) + elif TSHTournamentDataProvider.instance.provider.name == "ParryGG": + self.btLoadPlayerSet.setEnabled(False) else: self.btSelectSet.setText( QApplication.translate("app", "Load set")) self.btSelectSet.setEnabled(False) + self.btLoadStationSet.setEnabled(False) def SetCharacterNumber(self, value): # logger.info(f"TSHScoreboardWidget#SetCharacterNumber({value})") diff --git a/src/TSHTournamentDataProvider.py b/src/TSHTournamentDataProvider.py index 1c2323732..6a4874cb2 100644 --- a/src/TSHTournamentDataProvider.py +++ b/src/TSHTournamentDataProvider.py @@ -8,6 +8,7 @@ from .TSHGameAssetManager import TSHGameAssetManager from .TournamentDataProvider.TournamentDataProvider import TournamentDataProvider from .TournamentDataProvider.StartGGDataProvider import StartGGDataProvider +from .TournamentDataProvider.ParryGGDataProvider import ParryGGDataProvider from .Helpers.TSHVersionHelper import get_supported_providers from loguru import logger @@ -63,6 +64,9 @@ def SetGameFromProvider(self): if "start.gg" in self.provider.url: TSHGameAssetManager.instance.SetGameFromStartGGId( self.provider.videogame) + elif "parry.gg" in self.provider.url: + TSHGameAssetManager.instance.SetGameFromParryGGId( + self.provider.videogame) else: logger.error("Unsupported provider...") @@ -76,6 +80,17 @@ def SetTournament(self, url, initialLoading=False): TSHTournamentDataProvider.instance.provider = StartGGDataProvider( url, self.threadPool, self) url = TSHTournamentDataProvider.instance.provider.GetRealEventURL(url) + elif url is not None and "parry.gg" in url: + if not SettingsManager.Get("api_keys.parrygg"): + logger.error("ParryGG API key not set") + TSHTournamentDataProvider.instance.provider = None + else: + try: + TSHTournamentDataProvider.instance.provider = ParryGGDataProvider( + url, self.threadPool, self, SettingsManager.Get("api_keys.parrygg")) + except Exception as e: + logger.error(f"Failed to initialize ParryGG provider: {e}") + TSHTournamentDataProvider.instance.provider = None else: logger.error("Unsupported provider...") TSHTournamentDataProvider.instance.provider = None @@ -105,6 +120,9 @@ def SetTournamentSignal(self, url, initialLoading=False): if url is not None and "start.gg" in url: TSHTournamentDataProvider.instance.provider = StartGGDataProvider( url, self.threadPool, self) + elif url is not None and "parry.gg" in url: + TSHTournamentDataProvider.instance.provider = ParryGGDataProvider( + url, self.threadPool, self) else: logger.error("Unsupported provider...") TSHTournamentDataProvider.instance.provider = None @@ -139,7 +157,16 @@ def SetStartggEventSlug(self, mainWindow): okButton = QPushButton("OK") validators = [ QRegularExpression("start.gg/tournament/[^/]+/event[s]?/[^/]+"), - QRegularExpression("start.gg/admin/tournament/[^/]+/brackets/[^/]+") + QRegularExpression("start.gg/admin/tournament/[^/]+/brackets/[^/]+"), + + # This could maybe become just "parry.gg/[^/]+/[^/]+" + # + # But that form of url directs to the /main/bracket page anyway, + # so it's rare to see it shortened unless typing the url manually. + # + # Only downside is that the tournament management page would match + # (parry.gg/tournamentname/_manage) which doesn't include the event slug. + QRegularExpression("parry.gg/[^/]+/[^/]+/[^/]+") ] def validateText(): @@ -173,6 +200,16 @@ def validateText(): # Some URLs in startgg have eventS but the API doesn't work with that format url = url.replace("/events/", "/event/") + elif "parry.gg" in url: + # Remove the "_manage" part of admin urls first + url = url.replace("/_manage", "") + + matches = re.match( + "(.*parry.gg/[^/]*/[^/]*)", url) + + if matches: + url = matches.group() + SettingsManager.Set("TOURNAMENT_URL", url) TSHTournamentDataProvider.instance.SetTournament( SettingsManager.Get("TOURNAMENT_URL")) @@ -193,6 +230,10 @@ def SetUserAccount(self, window, startgg=False): if (self.provider and self.provider.url and "start.gg" in self.provider.url) or startgg: window_text = QApplication.translate( "app", "Paste the URL to the player's StartGG profile") + # TODO + # elif (self.provider and self.provider.url and "parry.gg" in self.provider.url): + # window_text = QApplication.translate( + # "app", "Paste the URL to the player's ParryGG profile") else: logger.error(QApplication.translate( "app", "Invalid tournament data provider")) diff --git a/src/TSHWebServerActions.py b/src/TSHWebServerActions.py index 78103969f..450d640fd 100644 --- a/src/TSHWebServerActions.py +++ b/src/TSHWebServerActions.py @@ -531,7 +531,12 @@ def load_tournament(self, url=None): return "OK" else: validators = [ - QRegularExpression("start.gg/tournament/[^/]+/event[s]?/[^/]+") + QRegularExpression("start.gg/tournament/[^/]+/event[s]?/[^/]+"), + + # This could maybe become just "parry.gg/[^/]+/[^/]+" + # But that form of url directs to the /main/bracket page anyway, + # so it's rare to see it shortened unless typing the url manually. + QRegularExpression("parry.gg/[^/]+/[^/]+/[^/]+") ] for validator in validators: @@ -548,6 +553,17 @@ def load_tournament(self, url=None): # Some URLs in startgg have eventS but the API doesn't work with that format url = url.replace("/events/", "/event/") + elif "parry.gg" in url: + # Remove the "_manage" part of admin urls first + url = url.replace("/_manage", "") + + matches = re.match( + "(.*parry.gg/[^/]*/[^/]*)", url) + + if matches: + url = matches.group() + + SettingsManager.Set("TOURNAMENT_URL", url) TSHTournamentDataProvider.instance.signals.tournament_url_update.emit(url) diff --git a/src/TournamentDataProvider/ParryGGDataProvider.py b/src/TournamentDataProvider/ParryGGDataProvider.py new file mode 100644 index 000000000..8bedd3e58 --- /dev/null +++ b/src/TournamentDataProvider/ParryGGDataProvider.py @@ -0,0 +1,416 @@ +import grpc +from loguru import logger + +# ParryGG Imports +# TODO: Probably won't use all of these. +from parrygg.services.tournament_service_pb2_grpc import TournamentServiceStub +from parrygg.services.event_service_pb2_grpc import EventServiceStub +from parrygg.services.phase_service_pb2_grpc import PhaseServiceStub +from parrygg.services.bracket_service_pb2_grpc import BracketServiceStub +from parrygg.services.match_service_pb2_grpc import MatchServiceStub +from parrygg.services.entrant_service_pb2_grpc import EntrantServiceStub +from parrygg.services.user_service_pb2_grpc import UserServiceStub +from parrygg.services.game_service_pb2_grpc import GameServiceStub + +# TODO: This is poor practice, will change later. +from parrygg.services.tournament_service_pb2 import * +from parrygg.services.event_service_pb2 import * +from parrygg.services.phase_service_pb2 import * +from parrygg.services.bracket_service_pb2 import * +from parrygg.services.match_service_pb2 import * +from parrygg.services.entrant_service_pb2 import * +from parrygg.services.user_service_pb2 import * +from parrygg.services.game_service_pb2 import * + +from parrygg.models.slug_pb2 import SlugType +from parrygg.models.bracket_pb2 import BracketType, MatchState +from parrygg.models.image_pb2 import ImageType + +# Other Imports used by StartGGDataProvider +# TODO: May or may not be required. +from .TournamentDataProvider import TournamentDataProvider +from ..TSHPlayerDB import TSHPlayerDB +# from ..Helpers.TSHCountryHelper import TSHCountryHelper +# from ..Helpers.TSHDictHelper import deep_get +# from ..Helpers.TSHDirHelper import TSHResolve +# from ..Helpers.TSHQtHelper import invokeSlot, gui_thread_sync +# from ..TSHGameAssetManager import TSHGameAssetManager +# import orjson +# from ..Helpers.TSHLocaleHelper import TSHLocaleHelper +# from ..TSHBracket import is_power_of_two +# from ..Workers import Worker + +class ParryGGDataProvider(TournamentDataProvider): + tournament_service = None + event_service = None + phase_service = None + bracket_service = None + match_service = None + matchgame_service = None + entrant_service = None + user_service = None + game_service = None + + tournament_slug = None + event_slug = None + tournament_id = None + event_id = None + + _timeout = 10 + metadata = None + + def __init__(self, url, threadpool, tshTdp, api_key=None) -> None: + super().__init__(url, threadpool, tshTdp) + self.name = "ParryGG" + self._initialized = False + + if api_key: + self.metadata = [("x-api-key", api_key)] + else: + logger.warning("No API key provided for ParryGG") + self.metadata = [] + + self._get_slugs_and_ids() + + def _get_slugs_and_ids(self): + if self._initialized: + return + + self.tournament_slug = self.url.split("parry.gg/")[1].split("/")[0] + self.event_slug = self.url.split("parry.gg/")[1].split("/")[1] + + self._setup_service("Tournament") + + try: + get_tournament_request = GetTournamentRequest() + get_tournament_request.tournament_slug = self.tournament_slug + get_tournament_response = self.tournament_service.GetTournament(get_tournament_request, metadata=self.metadata, timeout=self._timeout) + + self.tournament_id = get_tournament_response.tournament.id + + for event in get_tournament_response.tournament.events: + if event.slug == self.event_slug: + self.event_id = event.id + break + + self._initialized = True + + except grpc.RpcError as e: + if e.code() == grpc.StatusCode.UNAUTHENTICATED: + # logger.error("ParryGG authentication failed - invalid API key") + raise Exception("Invalid API key") + elif e.code() == grpc.StatusCode.NOT_FOUND: + raise Exception("Tournament or Event not found") + else: + logger.error(f"ParryGG gRPC error: {e}") + + def _setup_service(self, service_name): + if not hasattr(self, 'channel') or self.channel is None: + self.channel = grpc.secure_channel("api.parry.gg:443", grpc.ssl_channel_credentials()) + + match service_name: + case "Tournament": + if self.tournament_service is None: + self.tournament_service = TournamentServiceStub(self.channel) + case "Event": + if self.event_service is None: + self.event_service = EventServiceStub(self.channel) + case "Phase": + if self.phase_service is None: + self.phase_service = PhaseServiceStub(self.channel) + case "Bracket": + if self.bracket_service is None: + self.bracket_service = BracketServiceStub(self.channel) + case "Match": + if self.match_service is None: + self.match_service = MatchServiceStub(self.channel) + case "MatchGame": + if self.matchgame_service is None: + self.matchgame_service = MatchGameServiceStub(self.channel) + case "Entrant": + if self.entrant_service is None: + self.entrant_service = EntrantServiceStub(self.channel) + case "User": + if self.user_service is None: + self.user_service = UserServiceStub(self.channel) + case "Game": + if self.game_service is None: + self.game_service = GameServiceStub(self.channel) + case _: + logger.error(f"Service {service_name} not recognized") + + def GetIconURL(self): + self._setup_service("Tournament") + + get_tournament_request = GetTournamentRequest() + get_tournament_request.tournament_slug = self.tournament_slug + get_tournament_response = self.tournament_service.GetTournament(get_tournament_request, metadata=self.metadata, timeout=self._timeout) + + for image in get_tournament_response.tournament.images: + # Look for banner image, can be replaced in future if icons are added. + if image.type == ImageType.IMAGE_TYPE_BANNER: + return image.url + "?aspect_ratio=1:1" + + # Fallback to the ParryGG favicon if no suitable image is found. + logger.warning("No banner image found.") + return "https://parry.gg/assets/favicon-BgItT2B4.png" + + def GetEntrants(self): + self._setup_service("Event") + + players = [] + + try: + get_event_entrants_request = GetEventEntrantsRequest() + get_event_entrants_request.event_identifier.event_slug_path.tournament_slug = self.tournament_slug + get_event_entrants_request.event_identifier.event_slug_path.event_slug = self.event_slug + get_event_entrants_response = self.event_service.GetEventEntrants(get_event_entrants_request, metadata=self.metadata, timeout=self._timeout) + + for entrant in get_event_entrants_response.event_entrants: + # Assuming single user for now, no teams. + user = entrant.entrant.users[0] + + # Format the entrant correctly. + formatted_entrant = { + "prefix": "", + "gamerTag": user.gamer_tag, + "name": f"{user.first_name} {user.last_name}".strip(), + "id": [entrant.entrant.id], + "pronoun": user.pronouns, + "avatar": "", + "country_code": user.location_country, + "state_code": user.location_state, + "seed": entrant.seed + } + + # Extract avatar URL from images. + for image in user.images: + if image.type == ImageType.IMAGE_TYPE_AVATAR: + formatted_entrant["avatar"] = image.url + break + + players.append(formatted_entrant) + except Exception as e: + logger.error(f"Error processing entrants: {e}") + + TSHPlayerDB.AddPlayers(players) + + def GetTournamentData(self, progress_callback=None, cancel_event=None): + self._setup_service("Tournament") + + get_tournament_request = GetTournamentRequest() + get_tournament_request.tournament_slug = self.tournament_slug + get_tournament_response = self.tournament_service.GetTournament(get_tournament_request, metadata=self.metadata, timeout=self._timeout) + + tournament_data = get_tournament_response.tournament + + for event in tournament_data.events: + if event.slug == self.event_slug: + event_data = event + break + + tournament_info = {} + + try: + tournament_info["tournamentName"] = tournament_data.name + tournament_info["eventName"] = event_data.name + tournament_info["numEntrants"] = event_data.entrant_count + tournament_info["address"] = tournament_data.venue_address + tournament_info["startAt"] = tournament_data.start_date.seconds + tournament_info["endAt"] = tournament_data.end_date.seconds + tournament_info["eventStartAt"] = event_data.start_date.seconds + tournament_info["eventEndAt"] = "" + + for slug in tournament_data.slugs: + if slug.type == SlugType.SLUG_TYPE_CUSTOM: + tournament_info["shortLink"] = slug.slug + break + + videogame = event_data.game.slug + if videogame: + self.videogame = videogame + self.tshTdp.signals.game_changed.emit(videogame) + + except Exception as e: + logger.error(f"Error extracting tournament data: {e}") + + return tournament_info + + def GetMatch(self, setId, progress_callback=None, cancel_event=None): + # This is a really bad way of getting a match, but the GetMatch request + # doesn't include important data or provide a simple way to get it. + + matches = self.GetMatches(True) + + for match in matches: + if setId == match["id"]: + return match + + logger.error(f"Match {setId} not found") + return {} + + def GetMatches(self, getFinished=False, progress_callback=None, cancel_event=None): + self._setup_service("Match") + final_data = [] + + try: + get_matches_request = GetMatchesRequest() + get_matches_request.filter.event.id = self.event_id + get_matches_response = self.match_service.GetMatches(get_matches_request, metadata=self.metadata, timeout=self._timeout) + + for match_context in get_matches_response.matches: + match = match_context.match + seeds = match_context.seeds + + if not getFinished and match.state == MatchState.MATCH_STATE_COMPLETED: + continue + elif len(seeds) == 0: + continue + + match_info = {} + + match_info["id"] = match.id + match_info["team1score"] = int(match.slots[0].score) + match_info["team2score"] = int(match.slots[1].score) + match_info["round_name"] = "" + match_info["tournament_phase"] = "" + match_info["bracket_type"] = "" + match_info["p1_name"] = seeds[0].event_entrant.entrant.users[0].gamer_tag if len(seeds) > 0 and seeds[0].HasField("event_entrant") and seeds[0].event_entrant.HasField("entrant") and len(seeds[0].event_entrant.entrant.users) > 0 else "" + match_info["p2_name"] = seeds[1].event_entrant.entrant.users[0].gamer_tag if len(seeds) > 1 and seeds[1].HasField("event_entrant") and seeds[1].event_entrant.HasField("entrant") and len(seeds[1].event_entrant.entrant.users) > 0 else "" + match_info["p1_seed"] = seeds[0].seed if len(seeds) > 0 else "" + match_info["p2_seed"] = seeds[1].seed if len(seeds) > 1 else "" + match_info["stream"] = "" + match_info["station"] = "" + match_info["isOnline"] = "" + match_info["isPools"] = "" + match_info["round"] = match.round + match_info["entrants"] = [[] for _ in range(len(seeds))] if len(seeds) > 0 else [[]] + + for i, seed in enumerate(seeds): + if seed.HasField("event_entrant") and seed.event_entrant.HasField("entrant"): + entrant = seed.event_entrant.entrant + if len(entrant.users) > 0: + user = entrant.users[0] + match_info["entrants"][i].append({ + "prefix": user.sponsor_name, + "gamerTag": user.gamer_tag, + "name": (user.first_name + " " + user.last_name).strip(), + "id": [None, 0] + }) + + final_data.append(match_info) + + except Exception as e: + logger.error(f"Error processing matches: {e}") + + return final_data + + def GetStations(self, progress_callback=None, cancel_event=None): + logger.error("GetStations() called, returned []") + # TODO Get actual station/stream data, returning an empty list avoids a crash for now. + # NOTE Stations are not a short-term priority for parry.gg, but streams are actively being worked on. + return [] + + def GetStreamQueue(self, progress_callback=None, cancel_event=None): + logger.error("GetStreamQueue() called, returned {}") + # NOTE Stations are not a short-term priority for parry.gg, but streams are actively being worked on. + return {} + + def GetStreamMatchId(self, streamName): + logger.error("GetStreamMatchId() called, returned ''") + # NOTE Stations are not a short-term priority for parry.gg, but streams are actively being worked on. + return "" + + def GetStationMatchId(self, stationId): + # NOTE Stations are not a short-term priority for parry.gg, but streams are actively being worked on. + logger.error("GetStationMatchId() called") + sets = self.GetStationMatchsId(stationId) + + return sets[0] if len(sets) > 0 else None + + def GetStationMatchsId(self, stationId): + # NOTE Stations are not a short-term priority for parry.gg, but streams are actively being worked on. + logger.error("GetStationMatchsId() called, returned []") + return [] + + def GetUserMatchId(self, user): + logger.error("GetUserMatchId() called, returned ''") + return "" + + def GetRecentSets(self, id1, id2, videogame, callback, requestTime, progress_callback=None, cancel_event=None): + logger.error("GetRecentSets() called, returned []") + return [] + + def GetLastSets(self, playerID, playerNumber, callback, progress_callback=None, cancel_event=None): + logger.error("GetLastSets() called, returned []") + return [] + + def GetCompletedSets(self, progress_callback=None, cancel_event=None): + logger.error("GetCompletedSets() called, returned []") + return [] + + def GetPlayerHistoryStandings(self, playerID, playerNumber, gameType, callback, progress_callback=None, cancel_event=None): + logger.error("GetPlayerHistoryStandings() called, returned []") + return [] + + def GetTournamentPhases(self, progress_callback=None, cancel_event=None): + self._setup_service("Event") + + phases = [] + + try: + get_event_request = GetEventRequest() + get_event_request.id = self.event_id + get_event_response = self.event_service.GetEvent(get_event_request, metadata=self.metadata, timeout=self._timeout) + + for phase in get_event_response.event.phases: + phase_info = { + "id": phase.id, + "name": phase.name, + "groups": [] + } + + for bracket in phase.brackets: + bracket_info = { + "id": bracket.id, + "name": bracket.name, + "type": BracketType.Name(phase.bracket_type) + } + + phase_info["groups"].append(bracket_info) + + phases.append(phase_info) + + except Exception as e: + logger.error(f"Error getting tournament phases: {e}") + + return phases + + def GetTournamentPhaseGroup(self, id, progress_callback=None, cancel_event=None): + logger.error("GetTournamentPhaseGroup() called, returned {}") + return {} + + def GetStandings(self, playerNumber): + logger.error("GetStandings() called, returned []") + return [] + + def GetFutureMatch(self, matchId, progress_callback, cancel_event): + logger.error("GetFutureMatch() called, returned {}") + return {} + + def GetFutureMatchesList(self, setsId, progress_callback, cancel_event): + logger.error("GetFutureMatchesList() called, returned []") + return [] + + def cleanup(self): + """Properly cleanup gRPC channel and resources""" + if hasattr(self, 'channel') and self.channel: + try: + self.channel.close() + logger.info("gRPC channel closed successfully") + except Exception as e: + logger.error(f"Error closing gRPC channel: {e}") + + def __del__(self): + """Ensure cleanup happens when destroyed""" + self.cleanup() diff --git a/src/TournamentStreamHelper.py b/src/TournamentStreamHelper.py index c2a297f51..f96dca120 100755 --- a/src/TournamentStreamHelper.py +++ b/src/TournamentStreamHelper.py @@ -931,7 +931,7 @@ def Signal_GameChange(self, url): TSHGameAssetManager.instance.selectedGame = {} def UpdateUserSetButton(self): - if SettingsManager.Get("StartGG_user"): + if SettingsManager.Get("StartGG_user") and TSHTournamentDataProvider.instance and TSHTournamentDataProvider.instance.provider and TSHTournamentDataProvider.instance.provider.name == "StartGG": self.btLoadPlayerSet.setText( QApplication.translate("app", "Load tournament and sets from StartGG user")+" "+QApplication.translate("punctuation", "(")+f"{SettingsManager.Get('StartGG_user')}"+QApplication.translate("punctuation", ")")) self.btLoadPlayerSet.setEnabled(True)