diff --git a/steam_dice.py b/steam_dice.py
index ecc16c5..70c0c51 100755
--- a/steam_dice.py
+++ b/steam_dice.py
@@ -72,9 +72,10 @@ APPINFO_PATHS = [
]
-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."""
+def _load_taxonomy_from_appinfo(owned_appids, tags_table):
+ """Read genre + store_tag data for owned games from Steam's local appinfo.vdf.
+ Returns {appid_str: {"genres": [...], "tags": [...]}} or None if unavailable.
+ `tags_table` is {tagid_str: name}; pass {} to skip tag translation."""
try:
from steam.utils.appcache import parse_appinfo
except ImportError:
@@ -94,50 +95,104 @@ def _load_genres_from_appinfo(owned_appids):
common = (app.get("data", {})
.get("appinfo", {})
.get("common", {}))
- raw = common.get("genres") or {}
- names = []
- if isinstance(raw, dict):
- for v in raw.values():
+ genres = []
+ raw_g = common.get("genres") or {}
+ if isinstance(raw_g, dict):
+ for v in raw_g.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
+ if name and name not in genres:
+ genres.append(name)
+ tags = []
+ raw_t = common.get("store_tags") or {}
+ if isinstance(raw_t, dict):
+ for v in raw_t.values():
+ try:
+ tid = int(v)
+ except (TypeError, ValueError):
+ continue
+ name = tags_table.get(str(tid))
+ if name and name not in tags:
+ tags.append(name)
+ result[str(appid)] = {"genres": genres, "tags": tags}
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 _cache_dir():
+ base = os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache")
+ return os.path.join(base, "steam-dice")
-def _load_genre_cache():
+def _taxonomy_cache_path():
+ return os.path.join(_cache_dir(), "taxonomy.json")
+
+
+def _tags_table_path():
+ return os.path.join(_cache_dir(), "tags.json")
+
+
+def _load_taxonomy_cache():
try:
- with open(_genre_cache_path()) as f:
+ with open(_taxonomy_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):
+def _merge_taxonomy_into(target, source):
+ """Merge {appid: {genres, tags}} entries; preserve non-empty fields per appid."""
+ for aid, entry in source.items():
+ if not isinstance(entry, dict):
+ continue
+ existing = target.get(aid)
+ merged = dict(existing) if isinstance(existing, dict) else {}
+ for k in ("genres", "tags"):
+ new_val = entry.get(k)
+ if new_val:
+ merged[k] = new_val
+ elif k not in merged:
+ merged[k] = []
+ target[aid] = merged
+
+
+def _save_taxonomy_cache(cache):
"""Merge `cache` into the on-disk cache so concurrent writers don't shrink it."""
- path = _genre_cache_path()
+ path = _taxonomy_cache_path()
os.makedirs(os.path.dirname(path), exist_ok=True)
- merged = _load_genre_cache()
- merged.update(cache)
+ merged = _load_taxonomy_cache()
+ _merge_taxonomy_into(merged, cache)
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(merged, f)
os.replace(tmp, path)
+def _load_tags_table():
+ """{tagid_str: tag_name} for translating store_tag IDs to readable names."""
+ try:
+ with open(_tags_table_path()) as f:
+ data = json.load(f)
+ return data if isinstance(data, dict) else {}
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
+ return {}
+
+
+def _save_tags_table(table):
+ path = _tags_table_path()
+ os.makedirs(os.path.dirname(path), exist_ok=True)
+ tmp = path + ".tmp"
+ with open(tmp, "w") as f:
+ json.dump(table, f)
+ os.replace(tmp, path)
+
+
IMG_W = 460
IMG_H = 215
MARGIN = 20
@@ -235,6 +290,34 @@ class FetchLibraryThread(QThread):
self.error.emit(msg)
+class FetchTagsTableThread(QThread):
+ """One-shot fetch of Steam's full tag-id → name table."""
+ done = pyqtSignal(dict)
+ error = pyqtSignal(str)
+
+ def __init__(self, api_key):
+ super().__init__()
+ self.api_key = api_key
+
+ def run(self):
+ try:
+ url = (
+ "https://api.steampowered.com/IStoreService/GetTagList/v1/"
+ f"?key={self.api_key}&language=english"
+ )
+ r = requests.get(url, timeout=15)
+ r.raise_for_status()
+ tags = r.json().get("response", {}).get("tags", [])
+ table = {
+ str(t["tagid"]): t["name"]
+ for t in tags if "tagid" in t and "name" in t
+ }
+ self.done.emit(table)
+ except Exception as e:
+ msg = str(e).replace(self.api_key, "[REDACTED]")
+ self.error.emit(msg)
+
+
class FetchImageThread(QThread):
done = pyqtSignal(QPixmap)
@@ -286,25 +369,28 @@ class FetchGenresThread(QThread):
continue
r.raise_for_status()
entry = r.json().get(str(appid), {})
+ genres = []
if entry.get("success") and entry.get("data"):
- self.cache[str(appid)] = [
+ genres = [
g["description"] for g in entry["data"].get("genres", [])
]
- else:
- self.cache[str(appid)] = []
+ # Preserve any tags the appinfo path may have already populated.
+ prev = self.cache.get(str(appid))
+ prev_tags = prev.get("tags", []) if isinstance(prev, dict) else []
+ self.cache[str(appid)] = {"genres": genres, "tags": prev_tags}
except Exception:
pass # leave unfetched; retry on next session
if (i + 1) % self.SAVE_EVERY == 0:
- _save_genre_cache(self.cache)
+ _save_taxonomy_cache(self.cache)
self.progress.emit(i + 1, total, dict(self.cache))
self.msleep(self.REQUEST_INTERVAL_MS)
- _save_genre_cache(self.cache)
+ _save_taxonomy_cache(self.cache)
self.finished_ok.emit(self.cache)
-class GenreComboBox(QComboBox):
+class LazyComboBox(QComboBox):
"""QComboBox that intercepts the dropdown popup until permission is granted."""
popup_blocked = pyqtSignal()
@@ -446,8 +532,10 @@ class SteamDice(QMainWindow):
self.image_thread = None
self.cooldown_remaining = 0
self.current_appid = None
- self.genre_cache = _load_genre_cache()
+ self.taxonomy_cache = _load_taxonomy_cache()
+ self.tags_table = _load_tags_table()
self.genres_thread = None
+ self.tags_table_thread = None
self.setWindowTitle("Steam Dice")
self.setFixedSize(WIN_W, WIN_H)
@@ -478,7 +566,7 @@ class SteamDice(QMainWindow):
genre_col.setSpacing(4)
genre_col.setContentsMargins(0, 0, 0, 0)
- self.genre_combo = GenreComboBox()
+ self.genre_combo = LazyComboBox()
self.genre_combo.addItem("All genres")
self.genre_combo.setFixedHeight(28)
self.genre_combo.setStyleSheet(COMBO_STYLE)
@@ -497,9 +585,34 @@ class SteamDice(QMainWindow):
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:
+
+ # Tag filter (mirrors the genre column; uses store_tags from appinfo.vdf)
+ tag_col = QVBoxLayout()
+ tag_col.setSpacing(4)
+ tag_col.setContentsMargins(0, 0, 0, 0)
+
+ self.tag_combo = LazyComboBox()
+ self.tag_combo.addItem("All tags")
+ self.tag_combo.setFixedHeight(28)
+ self.tag_combo.setStyleSheet(COMBO_STYLE)
+ self.tag_combo.setEnabled(False)
+ self.tag_combo.currentIndexChanged.connect(self._apply_filter)
+ self.tag_combo.popup_blocked.connect(self._prompt_tags_fetch)
+ tag_col.addWidget(self.tag_combo)
+
+ # Spacer to keep the tag combo vertically aligned with the genre combo
+ # despite the genre column's extra progress-label row.
+ tag_spacer = QLabel()
+ tag_spacer.setFixedHeight(14)
+ tag_col.addWidget(tag_spacer)
+
+ top_row.addLayout(tag_col)
+
+ # When the cache already has data, allow the dropdowns to open immediately.
+ if any(isinstance(e, dict) and e.get("genres") for e in self.taxonomy_cache.values()):
self.genre_combo.set_allow_popup(True)
+ if any(isinstance(e, dict) and e.get("tags") for e in self.taxonomy_cache.values()):
+ self.tag_combo.set_allow_popup(True)
top_row.addStretch()
@@ -673,23 +786,62 @@ class SteamDice(QMainWindow):
self.installed_appids = _scan_installed_appids()
self.filter_combo.setEnabled(True)
self.genre_combo.setEnabled(True)
+ self.tag_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)
+ # Genres populate immediately even before the tag-id table is available;
+ # tags require a tags_table for translation, so they're empty if it's missing.
+ self._read_appinfo_into_cache()
self._rebuild_genre_combo()
+ self._rebuild_tag_combo()
self.refresh_btn.setEnabled(True)
self._apply_filter()
+ # Fetch the tag-id → name table once if we don't have it yet, then re-read
+ # appinfo so tag names land in the taxonomy cache.
+ if not self.tags_table:
+ self._fetch_tags_table()
+
+ def _read_appinfo_into_cache(self):
+ owned_ids = {g["appid"] for g in self.all_games}
+ local = _load_taxonomy_from_appinfo(owned_ids, self.tags_table)
+ if not local:
+ return
+ _merge_taxonomy_into(self.taxonomy_cache, local)
+ try:
+ _save_taxonomy_cache(self.taxonomy_cache)
+ except OSError:
+ pass
+ if any(e.get("genres") for e in local.values()):
+ self.genre_combo.set_allow_popup(True)
+ if any(e.get("tags") for e in local.values()):
+ self.tag_combo.set_allow_popup(True)
+
+ def _fetch_tags_table(self):
+ if self.tags_table_thread and self.tags_table_thread.isRunning():
+ return
+ api_key = keyring.get_password("steam-dice", "api_key") or ""
+ if not api_key:
+ return
+ self.tags_table_thread = FetchTagsTableThread(api_key)
+ self.tags_table_thread.done.connect(self._on_tags_table_loaded)
+ self.tags_table_thread.error.connect(lambda _msg: None) # silent; tags are optional
+ self.tags_table_thread.start()
+
+ def _on_tags_table_loaded(self, table):
+ if not table:
+ return
+ self.tags_table = table
+ try:
+ _save_tags_table(table)
+ except OSError:
+ pass
+ # Re-read appinfo now that we can translate tag IDs.
+ self._read_appinfo_into_cache()
+ self._rebuild_tag_combo()
+ self._apply_filter()
+
def _on_library_error(self, msg):
self.status_label.setText(f"Error loading library: {msg}")
self._cooldown_timer.stop()
@@ -709,7 +861,14 @@ class SteamDice(QMainWindow):
if genre:
games = [
g for g in games
- if genre in self.genre_cache.get(str(g["appid"]), [])
+ if genre in self.taxonomy_cache.get(str(g["appid"]), {}).get("genres", [])
+ ]
+
+ tag = self.tag_combo.currentData()
+ if tag:
+ games = [
+ g for g in games
+ if tag in self.taxonomy_cache.get(str(g["appid"]), {}).get("tags", [])
]
self.games = games
@@ -724,7 +883,9 @@ class SteamDice(QMainWindow):
"""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
+ g for entry in self.taxonomy_cache.values()
+ if isinstance(entry, dict)
+ for g in entry.get("genres", [])
})
self.genre_combo.blockSignals(True)
self.genre_combo.clear()
@@ -737,6 +898,25 @@ class SteamDice(QMainWindow):
self.genre_combo.setCurrentIndex(idx)
self.genre_combo.blockSignals(False)
+ def _rebuild_tag_combo(self):
+ """Rebuild the tag dropdown from the current cache, preserving selection."""
+ current = self.tag_combo.currentData()
+ all_tags = sorted({
+ t for entry in self.taxonomy_cache.values()
+ if isinstance(entry, dict)
+ for t in entry.get("tags", [])
+ })
+ self.tag_combo.blockSignals(True)
+ self.tag_combo.clear()
+ self.tag_combo.addItem("All tags", None)
+ for t in all_tags:
+ self.tag_combo.addItem(t, t)
+ if current:
+ idx = self.tag_combo.findData(current)
+ if idx >= 0:
+ self.tag_combo.setCurrentIndex(idx)
+ self.tag_combo.blockSignals(False)
+
def _prompt_genre_fetch(self):
if self.genres_thread and self.genres_thread.isRunning():
return
@@ -746,7 +926,7 @@ class SteamDice(QMainWindow):
"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]
+ missing = [g["appid"] for g in self.all_games if str(g["appid"]) not in self.taxonomy_cache]
if not missing:
self.genre_combo.set_allow_popup(True)
self.genre_combo.showPopup()
@@ -768,7 +948,7 @@ class SteamDice(QMainWindow):
if reply != QMessageBox.StandardButton.Yes:
return
- self.genres_thread = FetchGenresThread(missing, self.genre_cache)
+ self.genres_thread = FetchGenresThread(missing, self.taxonomy_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)
@@ -776,15 +956,53 @@ class SteamDice(QMainWindow):
self.genre_progress_label.setText(f"0 / {len(missing)}")
self.genres_thread.start()
+ def _prompt_tags_fetch(self):
+ # The tag combo only has data when both the tag-id table AND appinfo.vdf
+ # are available. There's no API fallback for tags, so just explain the
+ # situation if we can't populate it.
+ try:
+ from steam.utils.appcache import parse_appinfo # noqa: F401
+ has_steam = True
+ except ImportError:
+ has_steam = False
+
+ if not has_steam:
+ QMessageBox.information(
+ self, "Tag filtering unavailable",
+ "Tag filtering reads Steam's local appinfo.vdf cache via the "
+ "python-steam package, which isn't installed.
"
+ "Install it (e.g. pacman -S python-steam) and "
+ "restart Steam Dice to enable this filter."
+ )
+ return
+
+ if not self.tags_table:
+ if self.tags_table_thread and self.tags_table_thread.isRunning():
+ QMessageBox.information(
+ self, "Steam Dice",
+ "Loading tag list from Steam — try again in a moment."
+ )
+ else:
+ self._fetch_tags_table()
+ return
+
+ # Tags table is loaded but cache is empty — likely the appinfo.vdf path
+ # didn't exist when we tried earlier.
+ QMessageBox.information(
+ self, "Steam Dice",
+ "No tag data found in Steam's local cache. Launch Steam at least "
+ "once to populate appinfo.vdf, then refresh."
+ )
+
def _on_genres_progress(self, done, total, cache_snapshot):
- self.genre_cache.update(cache_snapshot)
+ _merge_taxonomy_into(self.taxonomy_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.update(cache)
+ _merge_taxonomy_into(self.taxonomy_cache, cache)
self.genre_progress_label.setVisible(False)
self._rebuild_genre_combo()
self._apply_filter()
@@ -852,6 +1070,8 @@ class SteamDice(QMainWindow):
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
+ if self.tags_table_thread and self.tags_table_thread.isRunning():
+ self.tags_table_thread.wait(15000)
super().closeEvent(a0)