feat: replace linux-gaming topics to ppdb.linux-gaming.ru api calls
All checks were successful
Code check / Check code (push) Successful in 1m19s

Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
This commit is contained in:
2026-01-26 23:09:02 +05:00
parent 3f0b3d65ad
commit 651040de70
4 changed files with 106 additions and 92 deletions

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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()

View File

@@ -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))