From 651040de7064ebe1f419ad0c01a98a22a844271d Mon Sep 17 00:00:00 2001 From: Boris Yumankulov Date: Mon, 26 Jan 2026 23:09:02 +0500 Subject: [PATCH] feat: replace linux-gaming topics to ppdb.linux-gaming.ru api calls Signed-off-by: Boris Yumankulov --- portprotonqt/detail_pages.py | 11 +-- portprotonqt/game_card.py | 12 +-- portprotonqt/main_window.py | 10 -- portprotonqt/portproton_api.py | 165 ++++++++++++++++++++------------- 4 files changed, 106 insertions(+), 92 deletions(-) diff --git a/portprotonqt/detail_pages.py b/portprotonqt/detail_pages.py index 97d107b..686fa0f 100644 --- a/portprotonqt/detail_pages.py +++ b/portprotonqt/detail_pages.py @@ -210,7 +210,7 @@ class DetailPageManager: portprotonLabel.setStyleSheet(self.main_window.theme.STEAM_BADGE_STYLE) portprotonLabel.setFixedWidth(badge_width) portprotonLabel.setVisible(portproton_visible) - portprotonLabel.clicked.connect(lambda: self.open_portproton_forum_topic(name)) + portprotonLabel.clicked.connect(lambda: self.portproton_api.open_ppdb_page(name, exec_line)) # WeAntiCheatYet badge anticheat_text = GameCard.getAntiCheatText(anticheat_status) @@ -869,12 +869,3 @@ class DetailPageManager: except Exception as e: logger.error(f"Error starting exit animation: {e}", exc_info=True) self._exit_animation_in_progress = False - - def open_portproton_forum_topic(self, name): - result = self.portproton_api.get_forum_topic_slug(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) diff --git a/portprotonqt/game_card.py b/portprotonqt/game_card.py index d00d62c..386183b 100644 --- a/portprotonqt/game_card.py +++ b/portprotonqt/game_card.py @@ -152,7 +152,7 @@ class GameCard(QFrame): self.portprotonLabel.setStyleSheet(self.theme.STEAM_BADGE_STYLE) self.portprotonLabel.setCardWidth(card_width) self.portprotonLabel.setVisible(self.portproton_visible) - self.portprotonLabel.clicked.connect(self.open_portproton_forum_topic) + self.portprotonLabel.clicked.connect(self.open_ppdb_page) anticheat_text = self.getAntiCheatText(anticheat_status) if anticheat_text: @@ -426,14 +426,8 @@ class GameCard(QFrame): return "broken" return "" - def open_portproton_forum_topic(self): - 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_ppdb_page(self): + self.portproton_api.open_ppdb_page(self.name, self.exec_line) def open_protondb_report(self): url = QUrl(f"https://www.protondb.com/app/{self.appid}") diff --git a/portprotonqt/main_window.py b/portprotonqt/main_window.py index 5125bbc..4dcb0b3 100644 --- a/portprotonqt/main_window.py +++ b/portprotonqt/main_window.py @@ -804,16 +804,6 @@ class MainWindow(QMainWindow): self.refreshButton.setText(_("Refresh Grid")) self.update_status_message.emit(_("Game library refreshed"), 3000) - 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 loadGames(self): display_filter = read_display_filter() favorites = read_favorites() diff --git a/portprotonqt/portproton_api.py b/portprotonqt/portproton_api.py index 58a401c..754dc83 100644 --- a/portprotonqt/portproton_api.py +++ b/portprotonqt/portproton_api.py @@ -1,5 +1,4 @@ import os -import tarfile import orjson import requests import urllib.parse @@ -8,13 +7,13 @@ import glob import re import hashlib from collections.abc import Callable -from PySide6.QtCore import QThread, Signal +from PySide6.QtCore import QThread, Signal, QUrl +from PySide6.QtGui import QDesktopServices from portprotonqt.downloader import Downloader from portprotonqt.logger import get_logger from portprotonqt.config_utils import get_portproton_location logger = get_logger(__name__) -CACHE_DURATION = 30 * 24 * 60 * 60 # 30 days in seconds AUTOINSTALL_CACHE_DURATION = 3600 # 1 hour for autoinstall cache def normalize_name(s): @@ -42,6 +41,49 @@ def normalize_name(s): filtered_words = [word for word in words if word not in keywords_to_remove] return " ".join(filtered_words) + +def extract_exe_name(exec_line: str) -> str: + """Extract executable name from exec_line. + + Handles various exec_line formats: + - Full command: 'env VAR=val /path/to/script /path/to/game.exe' -> 'game.exe' + - Autoinstall: 'autoinstall:script_name' -> '' + - Simple path: '/path/to/game.exe' -> 'game.exe' + + Returns: + Executable name with .exe extension, or empty string if not found + """ + import shlex + + if not exec_line: + return "" + + # Handle autoinstall scripts - they don't have a direct exe + if exec_line.startswith("autoinstall:"): + return "" + + try: + parts = shlex.split(exec_line) + # PortProton exec_line format: env VAR=val script game.exe + # The exe is usually the 4th part (index 3) or last part ending with .exe + if len(parts) >= 4: + game_exe = os.path.expanduser(parts[3]) + else: + # Fallback: find first .exe in the command + game_exe = exec_line + for part in parts: + if part.lower().endswith(".exe"): + game_exe = os.path.expanduser(part) + break + + exe_name = os.path.basename(game_exe) + # Ensure .exe extension + if exe_name and not exe_name.lower().endswith(".exe"): + exe_name = f"{exe_name}.exe" + return exe_name + except (ValueError, IndexError): + return "" + 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")) @@ -53,7 +95,6 @@ class PortProtonAPI: """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") @@ -61,8 +102,7 @@ class PortProtonAPI: self.portproton_location = get_portproton_location() self.repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) self.builtin_custom_folder = os.path.join(self.repo_root, "custom_data") - self._topics_data = None - self._autoinstall_cache = None # New: In-memory cache + self._autoinstall_cache = None # In-memory cache def _get_game_dir(self, exe_name: str) -> str: game_dir = os.path.join(self.custom_data_dir, exe_name) @@ -503,65 +543,64 @@ class PortProtonAPI: logger.info("Started background load of autoinstall games") return worker - 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 + def get_ppdb_url(self, game_name: str, exe_name: str) -> str: + """Get the PPDB URL for a given game. - 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") + Makes an API call to ppdb.linux-gaming.ru to look up the game by exe name. + If the returned name matches the game name, returns the direct URL. + Otherwise returns a search URL to avoid false positives (e.g., launcher.exe matches many games). - 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 = [] + Args: + game_name: Display name of the game + exe_name: Executable name (with or without .exe extension) - 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 = [] + Returns: + Full URL to the PPDB page or search page + """ + base_url = "https://ppdb.linux-gaming.ru" - 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 + # Ensure exe_name has .exe extension + if not exe_name.lower().endswith(".exe"): + exe_name = f"{exe_name}.exe" - 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 = normalize_name(game_name) - 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}" + api_url = f"{base_url}/api/lookup/exe/{urllib.parse.quote(exe_name)}" + + try: + response = requests.get(api_url, timeout=5) + if response.status_code == 200: + data = response.json() + api_name = data.get("name", "") + api_url_result = data.get("url", "") + + # Compare normalized names to avoid false positives + if api_name and api_url_result: + normalized_game = normalize_name(game_name) + normalized_api = normalize_name(api_name) + + if normalized_game == normalized_api: + logger.debug("PPDB exact match for %s: %s", game_name, api_url_result) + return api_url_result + + logger.debug( + "PPDB name mismatch for %s (exe: %s): API returned '%s', redirecting to search", + game_name, exe_name, api_name + ) + except requests.RequestException as e: + logger.debug("PPDB API request failed for %s: %s", exe_name, e) + except (ValueError, KeyError) as e: + logger.debug("PPDB API response parsing failed for %s: %s", exe_name, e) + + # Fallback to search URL + encoded_name = urllib.parse.quote(game_name) + return f"{base_url}/browse?search={encoded_name}" + + def open_ppdb_page(self, game_name: str, exec_line: str) -> None: + """Open the PPDB page for a game in the default browser. + + Args: + game_name: Display name of the game + exec_line: Exec line from which to extract the exe name + """ + exe_name = extract_exe_name(exec_line) + url = self.get_ppdb_url(game_name, exe_name) + QDesktopServices.openUrl(QUrl(url))