From aea1a36cfdff49728227376a01397cff3e938fec Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Thu, 10 Jul 2025 23:07:18 +0500 Subject: [PATCH] feat: open ppdb on portproton badge click Signed-off-by: Boris Yumankulov --- portprotonqt/game_card.py | 18 ++++++- portprotonqt/main_window.py | 12 ++++- portprotonqt/portproton_api.py | 85 ++++++++++++++++++++++++++++++++-- 3 files changed, 108 insertions(+), 7 deletions(-) diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index d480ab5..b5e9d52 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -9,6 +9,8 @@ from portprotonqt.config_utils import read_favorites, save_favorites, read_displ from portprotonqt.theme_manager import ThemeManager from portprotonqt.config_utils import read_theme_from_config from portprotonqt.custom_widgets import ClickableLabel +from portprotonqt.portproton_api import PortProtonAPI +from portprotonqt.downloader import Downloader import weakref from typing import cast @@ -56,6 +58,8 @@ class GameCard(QFrame): self.display_filter = read_display_filter() self.current_theme_name = read_theme_from_config() + self.downloader = Downloader(max_workers=4) + self.portproton_api = PortProtonAPI(self.downloader) self.steam_visible = (str(game_source).lower() == "steam" and self.display_filter in ("all", "favorites")) self.egs_visible = (str(game_source).lower() == "epic" and self.display_filter in ("all", "favorites")) @@ -194,13 +198,13 @@ class GameCard(QFrame): parent=coverWidget, icon_size=icon_size, icon_space=icon_space, - font_scale_factor=font_scale_factor, - change_cursor=False + font_scale_factor=font_scale_factor ) self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setFixedWidth(badge_width) self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setVisible(self.portproton_visible) + self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) # WeAntiCheatYet бейдж anticheat_text = self.getAntiCheatText(anticheat_status) @@ -385,6 +389,16 @@ class GameCard(QFrame): return "broken" return "" + def open_portproton_forum_topic(self): + """Open the PortProton forum topic or search page for this game.""" + result = self.portproton_api.get_forum_topic_slug(self.name) + base_url = "https://linux-gaming.ru/" + if result.startswith("search?q="): + url = QUrl(f"{base_url}{result}") + else: + url = QUrl(f"{base_url}t/{result}") + QDesktopServices.openUrl(url) + def open_protondb_report(self): url = QUrl(f"https://www.protondb.com/app/{self.appid}") QDesktopServices.openUrl(url) diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index ccfb068..9a62fcf 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -248,6 +248,16 @@ class MainWindow(QMainWindow): self.updateGameGrid() self.progress_bar.setVisible(False) + def open_portproton_forum_topic(self, topic_name: str): + """Open the PortProton forum topic or search page for this game.""" + result = self.portproton_api.get_forum_topic_slug(topic_name) + base_url = "https://linux-gaming.ru/" + if result.startswith("search?q="): + url = QUrl(f"{base_url}{result}") + else: + url = QUrl(f"{base_url}t/{result}") + QDesktopServices.openUrl(url) + def _on_card_focused(self, game_name: str, is_focused: bool): """Обработчик сигнала focusChanged от GameCard.""" card_key = None @@ -1639,11 +1649,11 @@ class MainWindow(QMainWindow): parent=coverFrame, icon_size=16, icon_space=5, - change_cursor=False ) portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) portprotonLabel.setFixedWidth(badge_width) portprotonLabel.setVisible(portproton_visible) + portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name)) # WeAntiCheatYet бейдж anticheat_text = GameCard.getAntiCheatText(anticheat_status) diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index 0569b74..d453354 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -1,19 +1,33 @@ import os +import tarfile +import orjson import requests +import urllib.parse +import time from collections.abc import Callable -from portprotonqt.downloader import Downloader, download_with_cache +from portprotonqt.downloader import Downloader from portprotonqt.logger import get_logger logger = get_logger(__name__) +CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds + +def get_cache_dir(): + """Return the cache directory path, creating it if necessary.""" + xdg_cache_home = os.getenv("XDG_CACHE_HOME", os.path.join(os.path.expanduser("~"), ".cache")) + cache_dir = os.path.join(xdg_cache_home, "PortProtonQt") + os.makedirs(cache_dir, exist_ok=True) + return cache_dir class PortProtonAPI: - """API to fetch game assets (cover, metadata) from the PortProtonQt repository.""" + """API to fetch game assets (cover, metadata) and forum topics from the PortProtonQt repository.""" def __init__(self, downloader: Downloader | None = None): self.base_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/portprotonqt/custom_data" + self.topics_url = "https://git.linux-gaming.ru/Boria138/PortProtonQt/raw/branch/main/data/linux_gaming_topics.tar.xz" self.downloader = downloader or Downloader(max_workers=4) self.xdg_data_home = os.getenv("XDG_DATA_HOME", os.path.join(os.path.expanduser("~"), ".local", "share")) self.custom_data_dir = os.path.join(self.xdg_data_home, "PortProtonQt", "custom_data") os.makedirs(self.custom_data_dir, exist_ok=True) + self._topics_data = None def _get_game_dir(self, exe_name: str) -> str: game_dir = os.path.join(self.custom_data_dir, exe_name) @@ -40,7 +54,7 @@ class PortProtonAPI: cover_url = f"{cover_url_base}{ext}" if self._check_file_exists(cover_url, timeout): local_cover_path = os.path.join(game_dir, f"cover{ext}") - result = download_with_cache(cover_url, local_cover_path, timeout, self.downloader) + result = self.downloader.download(cover_url, local_cover_path, timeout=timeout) if result: results["cover"] = result logger.info(f"Downloaded cover for {exe_name} to {result}") @@ -52,7 +66,7 @@ class PortProtonAPI: if self._check_file_exists(metadata_url, timeout): local_metadata_path = os.path.join(game_dir, "metadata.txt") - result = download_with_cache(metadata_url, local_metadata_path, timeout, self.downloader) + result = self.downloader.download(metadata_url, local_metadata_path, timeout=timeout) if result: results["metadata"] = result logger.info(f"Downloaded metadata for {exe_name} to {result}") @@ -123,3 +137,66 @@ class PortProtonAPI: logger.debug(f"No assets found for {exe_name}") if callback: callback(results) + + def _load_topics_data(self): + """Load and cache linux_gaming_topics_min.json from the archive.""" + if self._topics_data is not None: + return self._topics_data + + cache_dir = get_cache_dir() + cache_tar = os.path.join(cache_dir, "linux_gaming_topics.tar.xz") + cache_json = os.path.join(cache_dir, "linux_gaming_topics_min.json") + + if os.path.exists(cache_json) and (time.time() - os.path.getmtime(cache_json) < CACHE_DURATION): + logger.info("Using cached topics JSON: %s", cache_json) + try: + with open(cache_json, "rb") as f: + self._topics_data = orjson.loads(f.read()) + logger.debug("Loaded %d topics from cache", len(self._topics_data)) + return self._topics_data + except Exception as e: + logger.error("Error reading cached topics JSON: %s", e) + self._topics_data = [] + + def process_tar(result: str | None): + if not result or not os.path.exists(result): + logger.error("Failed to download topics archive") + self._topics_data = [] + return + try: + with tarfile.open(result, mode="r:xz") as tar: + member = next((m for m in tar.getmembers() if m.name == "linux_gaming_topics_min.json"), None) + if member is None: + raise RuntimeError("linux_gaming_topics_min.json not found in archive") + fobj = tar.extractfile(member) + if fobj is None: + raise RuntimeError("Failed to extract linux_gaming_topics_min.json from archive") + raw = fobj.read() + fobj.close() + self._topics_data = orjson.loads(raw) + with open(cache_json, "wb") as f: + f.write(orjson.dumps(self._topics_data)) + if os.path.exists(cache_tar): + os.remove(cache_tar) + logger.info("Archive %s deleted after extraction", cache_tar) + logger.info("Loaded %d topics from archive", len(self._topics_data)) + except Exception as e: + logger.error("Error processing topics archive: %s", e) + self._topics_data = [] + + self.downloader.download_async(self.topics_url, cache_tar, timeout=5, callback=process_tar) + # Wait for async download to complete if called synchronously + while self._topics_data is None: + time.sleep(0.1) + return self._topics_data + + def get_forum_topic_slug(self, game_name: str) -> str: + """Get the forum topic slug or search URL for a given game name.""" + topics = self._load_topics_data() + normalized_name = game_name.lower().replace(" ", "-") + for topic in topics: + if topic["normalized_title"] == normalized_name: + return topic["slug"] + logger.debug("No forum topic found for game: %s, redirecting to search", game_name) + encoded_name = urllib.parse.quote(f"#ppdb {game_name}") + return f"search?q={encoded_name}"