From bb50d96999294bae962b475202aef039111ea94f Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 8 May 2026 15:26:53 -0400 Subject: [PATCH 1/2] Add genre filter using local appinfo.vdf with API fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second dropdown next to the install filter that lets the user narrow rolls to a single Steam genre (RPG, Strategy, etc.). Genre data is loaded from Steam's local appinfo.vdf cache via python-steam (instant, no network). If that's unavailable or a game isn't in the cache, falls back to a rate-limited background fetch of appdetails?filters=genres, prompted via confirm dialog. Cached at ~/.cache/steam-dice/genres.json across runs. python-steam declared as optdepends — app degrades gracefully to the API path if missing. --- PKGBUILD | 1 + steam_dice.py | 290 +++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 287 insertions(+), 4 deletions(-) diff --git a/PKGBUILD b/PKGBUILD index 3b9a7e1..fe219d8 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -8,6 +8,7 @@ arch=('any') url="https://github.com/silvernode/steam-dice" license=('GPL-2.0-only') depends=('python' 'python-pyqt6' 'python-requests' 'python-keyring' 'xdg-utils') +optdepends=('python-steam: instant genre filter via Steam'\''s local appinfo.vdf cache (otherwise falls back to rate-limited Steam API)') makedepends=('git') provides=("$_pkgname") conflicts=("$_pkgname") diff --git a/steam_dice.py b/steam_dice.py index 3406d45..52801a8 100755 --- a/steam_dice.py +++ b/steam_dice.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 import glob +import json import os import re import sys @@ -12,7 +13,8 @@ import keyring if os.environ.get("WAYLAND_DISPLAY"): os.environ.setdefault("QT_QPA_PLATFORM", "wayland") from PyQt6.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, - QPushButton, QLabel, QComboBox, QDialog, QDialogButtonBox, QLineEdit) + QPushButton, QLabel, QComboBox, QDialog, QDialogButtonBox, QLineEdit, + QMessageBox) from PyQt6.QtCore import Qt, QSettings, QThread, QTimer, pyqtSignal from PyQt6.QtGui import QPixmap, QFont, QIcon @@ -50,6 +52,89 @@ def _scan_installed_appids(): return installed +# Steam's stable numeric genre IDs. Cross-referenced with the appdetails API. +# Unknown IDs (e.g. 34, 73, internal/deprecated) are silently dropped. +STEAM_GENRE_NAMES = { + 1: "Action", 2: "Strategy", 3: "RPG", 4: "Casual", 9: "Racing", + 18: "Sports", 23: "Indie", 25: "Adventure", 28: "Simulation", + 29: "Massively Multiplayer", 37: "Free To Play", + 51: "Animation & Modeling", 52: "Audio Production", + 53: "Design & Illustration", 54: "Education", 55: "Photo Editing", + 56: "Software Training", 57: "Utilities", 58: "Video Production", + 59: "Web Publishing", 60: "Game Development", + 70: "Early Access", +} + +APPINFO_PATHS = [ + "~/.local/share/Steam/appcache/appinfo.vdf", + "~/.steam/steam/appcache/appinfo.vdf", + "~/.steam/root/appcache/appinfo.vdf", +] + + +def _load_genres_from_appinfo(owned_appids): + """Read genre data for owned games from Steam's local appinfo.vdf. + Returns {appid_str: [genre_names]} or None if unavailable.""" + try: + from steam.utils.appcache import parse_appinfo + except ImportError: + return None + wanted = set(owned_appids) + for path in (os.path.expanduser(p) for p in APPINFO_PATHS): + if not os.path.exists(path): + continue + try: + with open(path, "rb") as f: + _, apps_iter = parse_appinfo(f, mapper=dict) + result = {} + for app in apps_iter: + appid = app.get("appid") + if appid not in wanted: + continue + common = (app.get("data", {}) + .get("appinfo", {}) + .get("common", {})) + raw = common.get("genres") or {} + names = [] + if isinstance(raw, dict): + for v in raw.values(): + try: + gid = int(v) + except (TypeError, ValueError): + continue + name = STEAM_GENRE_NAMES.get(gid) + if name and name not in names: + names.append(name) + result[str(appid)] = names + return result + except Exception: + continue + return None + + +def _genre_cache_path(): + cache_dir = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache") + return os.path.join(cache_dir, "steam-dice", "genres.json") + + +def _load_genre_cache(): + try: + with open(_genre_cache_path()) as f: + data = json.load(f) + return data if isinstance(data, dict) else {} + except (FileNotFoundError, json.JSONDecodeError, OSError): + return {} + + +def _save_genre_cache(cache): + path = _genre_cache_path() + os.makedirs(os.path.dirname(path), exist_ok=True) + tmp = path + ".tmp" + with open(tmp, "w") as f: + json.dump(cache, f) + os.replace(tmp, path) + + IMG_W = 460 IMG_H = 215 MARGIN = 20 @@ -166,6 +251,74 @@ class FetchImageThread(QThread): self.done.emit(QPixmap()) +class FetchGenresThread(QThread): + progress = pyqtSignal(int, int, dict) # done, total, cache snapshot + finished_ok = pyqtSignal(dict) # final cache + + REQUEST_INTERVAL_MS = 2000 # ~30 req/min, well under Steam's ~200/5min cap + SAVE_EVERY = 10 + + def __init__(self, appids, existing_cache): + super().__init__() + self.appids = list(appids) + self.cache = dict(existing_cache) + self._stop = False + + def stop(self): + self._stop = True + + def run(self): + total = len(self.appids) + for i, appid in enumerate(self.appids): + if self._stop: + break + try: + url = ( + "https://store.steampowered.com/api/appdetails" + f"?appids={appid}&filters=genres" + ) + r = requests.get(url, timeout=10) + if r.status_code == 429: + self.msleep(60_000) + continue + r.raise_for_status() + entry = r.json().get(str(appid), {}) + if entry.get("success") and entry.get("data"): + self.cache[str(appid)] = [ + g["description"] for g in entry["data"].get("genres", []) + ] + else: + self.cache[str(appid)] = [] + except Exception: + pass # leave unfetched; retry on next session + + if (i + 1) % self.SAVE_EVERY == 0: + _save_genre_cache(self.cache) + self.progress.emit(i + 1, total, dict(self.cache)) + self.msleep(self.REQUEST_INTERVAL_MS) + + _save_genre_cache(self.cache) + self.finished_ok.emit(self.cache) + + +class GenreComboBox(QComboBox): + """QComboBox that intercepts the dropdown popup until permission is granted.""" + popup_blocked = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self._allow_popup = False + + def set_allow_popup(self, allow): + self._allow_popup = allow + + def showPopup(self): + if not self._allow_popup: + self.popup_blocked.emit() + return + super().showPopup() + + DIALOG_STYLE = """ QDialog, QWidget { background-color: #1b2838; } QLabel { color: #c6d4df; } @@ -290,6 +443,8 @@ class SteamDice(QMainWindow): self.image_thread = None self.cooldown_remaining = 0 self.current_appid = None + self.genre_cache = _load_genre_cache() + self.genres_thread = None self.setWindowTitle("Steam Dice") self.setFixedSize(WIN_W, WIN_H) @@ -315,6 +470,34 @@ class SteamDice(QMainWindow): self.filter_combo.currentIndexChanged.connect(self._apply_filter) top_row.addWidget(self.filter_combo, alignment=Qt.AlignmentFlag.AlignTop) + # Genre filter (with progress sub-label for first-time fetch) + genre_col = QVBoxLayout() + genre_col.setSpacing(4) + genre_col.setContentsMargins(0, 0, 0, 0) + + self.genre_combo = GenreComboBox() + self.genre_combo.addItem("All genres") + self.genre_combo.setFixedHeight(28) + self.genre_combo.setStyleSheet(COMBO_STYLE) + self.genre_combo.setEnabled(False) + self.genre_combo.currentIndexChanged.connect(self._apply_filter) + self.genre_combo.popup_blocked.connect(self._prompt_genre_fetch) + genre_col.addWidget(self.genre_combo) + + self.genre_progress_label = QLabel() + self.genre_progress_label.setFixedHeight(14) + progress_font = QFont() + progress_font.setPointSize(8) + self.genre_progress_label.setFont(progress_font) + self.genre_progress_label.setStyleSheet("color: #4a5a6a;") + self.genre_progress_label.setVisible(False) + genre_col.addWidget(self.genre_progress_label) + + top_row.addLayout(genre_col) + # When the cache already has data, allow the dropdown to open immediately. + if self.genre_cache: + self.genre_combo.set_allow_popup(True) + top_row.addStretch() refresh_col = QVBoxLayout() @@ -486,6 +669,21 @@ class SteamDice(QMainWindow): self.all_games = games self.installed_appids = _scan_installed_appids() self.filter_combo.setEnabled(True) + self.genre_combo.setEnabled(True) + + # Try Steam's local appinfo.vdf cache first — instant, no network. + owned_ids = {g["appid"] for g in games} + local_genres = _load_genres_from_appinfo(owned_ids) + if local_genres: + for aid, names in local_genres.items(): + self.genre_cache.setdefault(aid, names) + try: + _save_genre_cache(self.genre_cache) + except OSError: + pass + self.genre_combo.set_allow_popup(True) + + self._rebuild_genre_combo() self.refresh_btn.setEnabled(True) self._apply_filter() @@ -498,11 +696,20 @@ class SteamDice(QMainWindow): def _apply_filter(self): idx = self.filter_combo.currentIndex() if idx == 1: - self.games = [g for g in self.all_games if g["appid"] in self.installed_appids] + games = [g for g in self.all_games if g["appid"] in self.installed_appids] elif idx == 2: - self.games = [g for g in self.all_games if g["appid"] not in self.installed_appids] + games = [g for g in self.all_games if g["appid"] not in self.installed_appids] else: - self.games = list(self.all_games) + games = list(self.all_games) + + genre = self.genre_combo.currentData() + if genre: + games = [ + g for g in games + if genre in self.genre_cache.get(str(g["appid"]), []) + ] + + self.games = games count = len(self.games) if count: self.status_label.setText(f"{count} games — roll the dice!") @@ -510,6 +717,75 @@ class SteamDice(QMainWindow): self.status_label.setText("No games match this filter.") self.dice_btn.setEnabled(bool(self.games)) + def _rebuild_genre_combo(self): + """Rebuild the genre dropdown from the current cache, preserving selection.""" + current = self.genre_combo.currentData() + all_genres = sorted({ + g for genres in self.genre_cache.values() for g in genres + }) + self.genre_combo.blockSignals(True) + self.genre_combo.clear() + self.genre_combo.addItem("All genres", None) + for g in all_genres: + self.genre_combo.addItem(g, g) + if current: + idx = self.genre_combo.findData(current) + if idx >= 0: + self.genre_combo.setCurrentIndex(idx) + self.genre_combo.blockSignals(False) + + def _prompt_genre_fetch(self): + if self.genres_thread and self.genres_thread.isRunning(): + return + if not self.all_games: + QMessageBox.information( + self, "Steam Dice", + "Library is still loading — try again in a moment." + ) + return + missing = [g["appid"] for g in self.all_games if str(g["appid"]) not in self.genre_cache] + if not missing: + self.genre_combo.set_allow_popup(True) + self.genre_combo.showPopup() + return + + seconds = (len(missing) * FetchGenresThread.REQUEST_INTERVAL_MS) // 1000 + minutes = seconds // 60 + eta = f"~{minutes} min" if minutes >= 1 else f"~{seconds} sec" + reply = QMessageBox.question( + self, "Load genres from Steam?", + f"Genre filtering needs to fetch genre data for {len(missing)} games " + f"from Steam's store API.\n\n" + f"This takes {eta} (rate-limited to one request every " + f"{FetchGenresThread.REQUEST_INTERVAL_MS // 1000}s) and is cached " + f"afterward. The app stays usable while it runs.\n\nContinue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if reply != QMessageBox.StandardButton.Yes: + return + + self.genres_thread = FetchGenresThread(missing, self.genre_cache) + self.genres_thread.progress.connect(self._on_genres_progress) + self.genres_thread.finished_ok.connect(self._on_genres_done) + self.genre_combo.set_allow_popup(True) + self.genre_progress_label.setVisible(True) + self.genre_progress_label.setText(f"0 / {len(missing)}") + self.genres_thread.start() + + def _on_genres_progress(self, done, total, cache_snapshot): + self.genre_cache = cache_snapshot + self.genre_progress_label.setText(f"{done} / {total}") + # Refresh the dropdown periodically as new genres are discovered. + if done % FetchGenresThread.SAVE_EVERY == 0: + self._rebuild_genre_combo() + + def _on_genres_done(self, cache): + self.genre_cache = cache + self.genre_progress_label.setVisible(False) + self._rebuild_genre_combo() + self._apply_filter() + def _refresh(self): self.refresh_btn.setEnabled(False) self.dice_btn.setEnabled(False) @@ -569,6 +845,12 @@ class SteamDice(QMainWindow): if self.current_appid is not None: subprocess.Popen(["xdg-open", f"steam://rungameid/{self.current_appid}"]) + def closeEvent(self, a0): + if self.genres_thread and self.genres_thread.isRunning(): + self.genres_thread.stop() + self.genres_thread.wait(12000) # cover in-flight 10s HTTP timeout + final save + super().closeEvent(a0) + if __name__ == "__main__": app = QApplication(sys.argv) From b5dc6fb5473a3012767a4aea040bb90e860c381b Mon Sep 17 00:00:00 2001 From: Mollusk Date: Fri, 8 May 2026 15:31:24 -0400 Subject: [PATCH 2/2] Fix genre cache merge race when API thread runs alongside main thread _save_genre_cache now reads the on-disk state and merges before writing, so a concurrent writer can't shrink the cache. Likewise the API thread's progress/done handlers now `update()` the in-memory cache instead of replacing it wholesale. Reachable only in the rare path where both the appinfo loader and the API fetch thread run in the same session. --- steam_dice.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/steam_dice.py b/steam_dice.py index 52801a8..ecc16c5 100755 --- a/steam_dice.py +++ b/steam_dice.py @@ -127,11 +127,14 @@ def _load_genre_cache(): def _save_genre_cache(cache): + """Merge `cache` into the on-disk cache so concurrent writers don't shrink it.""" path = _genre_cache_path() os.makedirs(os.path.dirname(path), exist_ok=True) + merged = _load_genre_cache() + merged.update(cache) tmp = path + ".tmp" with open(tmp, "w") as f: - json.dump(cache, f) + json.dump(merged, f) os.replace(tmp, path) @@ -774,14 +777,14 @@ class SteamDice(QMainWindow): self.genres_thread.start() def _on_genres_progress(self, done, total, cache_snapshot): - self.genre_cache = cache_snapshot + self.genre_cache.update(cache_snapshot) self.genre_progress_label.setText(f"{done} / {total}") # Refresh the dropdown periodically as new genres are discovered. if done % FetchGenresThread.SAVE_EVERY == 0: self._rebuild_genre_combo() def _on_genres_done(self, cache): - self.genre_cache = cache + self.genre_cache.update(cache) self.genre_progress_label.setVisible(False) self._rebuild_genre_combo() self._apply_filter()