14 Commits

Author SHA1 Message Date
mollusk 4494fe979c Bump pkgver to r44.3e5a554
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:06:01 -04:00
mollusk 3e5a554a59 Merge branch 'feature/api-key-domain-clarity' 2026-05-09 20:05:22 -04:00
mollusk dc69aa7d30 Clarify Steam API key domain field is not validated
Adds a visible callout in the settings dialog and a "Don't have a
domain?" note + common-failure list in the README, so users (and the
friends they share the app with) don't bounce off the registration
page thinking they need to own a real domain.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 20:05:19 -04:00
mollusk f8f8e9ab18 Release v0.2.0
Highlights since v0.1.0:
- Store Page button next to Play
- Opt-in store-price label with four format styles, 15-min in-memory cache
- Updated README and screenshot

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:55:32 -04:00
mollusk e28646ad1a Bump pkgver to r40.7b5e777
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:52:16 -04:00
mollusk 7b5e777277 Merge branch 'feature/readme-store-features' 2026-05-09 19:51:55 -04:00
mollusk a7db5c8bc0 Update README + screenshot for Store Page button and price label
Adds bullets covering the new Store Page action, opt-in store-price
label, and the settings entry that controls them. Refreshes the
screenshot to show the current layout (Balatro on sale, formatted
in full strike+sale+% style).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:51:51 -04:00
mollusk 15ff3527e8 Bump pkgver to r37.052b6d4
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:48:37 -04:00
mollusk 052b6d43c5 Merge branch 'feature/store-price' 2026-05-09 19:48:13 -04:00
mollusk 47abb10d6e Add opt-in store price label
Settings checkbox (off by default) toggles a price label between the
title and the cover image. Format combo offers four styles
(final / final+%, strikethrough, full strike+sale+%). Prices come from
the public appdetails endpoint, cached in-memory for 15 minutes.
"Free" / "—" cover F2P / unavailable apps.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:48:10 -04:00
mollusk 3b852d0f5a Bump pkgver to r34.3e84c3a
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:36:05 -04:00
mollusk 3e84c3a210 Merge branch 'feature/store-page-button' 2026-05-09 19:35:12 -04:00
mollusk 2221636e18 Add Store Page button next to Play
Opens https://store.steampowered.com/app/<appid>/ via xdg-open for
the currently rolled game. Toggles visibility in lockstep with Play.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-09 19:35:10 -04:00
mollusk b43eb11924 Use relative path for README screenshot
The previous absolute URL pointed at the Gitea instance and was
proxied by GitHub's camo image cache, which kept serving an old
copy after the screenshot was updated. A relative path lets each
forge (GitHub, Gitea) resolve and serve the image from its own
repo blob, bypassing camo.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 21:17:18 -04:00
4 changed files with 238 additions and 18 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# Maintainer: Mollusk <silvernode@gmail.com>
pkgname=steam-dice-git
_pkgname=steam-dice
pkgver=r30.4ba3aa0
pkgver=r44.3e5a554
pkgrel=1
pkgdesc="A PyQt6 desktop app that picks a random game from your Steam library"
arch=('any')
+16 -5
View File
@@ -2,7 +2,7 @@
A small desktop app that picks a random game from your Steam library and lets you launch it directly. Stop staring at your backlog — let the dice decide.
![Steam Dice screenshot](https://gitbutter.xyz/mollusk/steam-dice/raw/branch/main/screenshot.jpg)
![Steam Dice screenshot](screenshot.jpg)
## Features
@@ -13,6 +13,8 @@ A small desktop app that picks a random game from your Steam library and lets yo
- **Tags** — multi-select popup with a search box; a game must carry every selected tag to survive
- **Friends** — multi-select popup; picks games every selected friend also owns, perfect for "what should we play tonight?"
- **Play** button launches the selected game immediately via Steam
- **Store Page** button opens the rolled game's Steam store page in your browser — handy for checking screenshots, reviews, or gifting it to a friend
- **Optional store-price label** (off by default) shows the current store price under the title, with a configurable format (final only, with discount %, strikethrough original, or full strike + sale + %) — useful for spotting sales when picking a gift
- Settings dialog for your API key and Steam ID, persisted across sessions (API key kept in your system keyring)
- Refresh button with a 60-second cooldown to avoid hammering the Steam API; refreshing also re-fetches selected friends' libraries
- Clean Steam-themed dark UI built with PyQt6
@@ -51,9 +53,17 @@ pacman -S python-steam
You need a free Steam Web API key to fetch your library.
1. Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) and log in.
2. Enter any domain name (e.g. `localhost`) and click **Register**.
3. Copy the 32-character key shown on the page.
1. Go to [steamcommunity.com/dev/apikey](https://steamcommunity.com/dev/apikey) and log in with your Steam account.
2. In the **Domain Name** field, type any string and click **Register**.
3. Copy the 32-character key shown on the next page — paste it into Steam Dice's settings dialog.
> **Don't have a domain?** You don't need one. Steam's "Domain Name" field is **not validated** — `localhost`, your name, `personal`, or any other text works. It's just a label Steam stores alongside your key; it doesn't have to be a real domain you own.
If the page refuses to issue a key, the usual causes are:
- Your Steam account has spent **less than $5** lifetime on Steam (Valve's anti-spam threshold for API access).
- Your account is **limited** (new account with no purchases) or has an unverified email.
- You're signed into the wrong Steam account in your browser.
### 2. Steam ID (64-bit)
@@ -84,8 +94,9 @@ Once loaded:
- **Tags ▾** — opens a popup with a search box and checkboxes; selected tags are AND'd, so a game must carry every checked tag
- **Friends ▾** — opens a popup listing your Steam friends; check any number to keep only games every selected friend also owns
- Click **Play** to launch the rolled game via Steam
- Click **Store Page** to open the rolled game's Steam store page in your browser
- Click ⟳ to re-fetch your library (60s cooldown applies); selected friends' libraries refresh too
- Click ⚙ to update your credentials at any time
- Click ⚙ to update your credentials, toggle the store-price label, or change its format
### Friends filter prerequisites
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 60 KiB

+221 -12
View File
@@ -4,6 +4,7 @@ import json
import os
import re
import sys
import time
import random
import subprocess
import requests
@@ -13,12 +14,12 @@ 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,
QMessageBox, QFrame, QListWidget, QListWidgetItem)
QPushButton, QLabel, QComboBox, QCheckBox, QDialog, QDialogButtonBox,
QLineEdit, QMessageBox, QFrame, QListWidget, QListWidgetItem)
from PyQt6.QtCore import Qt, QPoint, QSettings, QThread, QTimer, pyqtSignal
from PyQt6.QtGui import QPixmap, QFont, QIcon
VERSION = "v0.1.0"
VERSION = "v0.2.0"
def _get_version():
try:
@@ -250,11 +251,21 @@ IMG_H = 215
MARGIN = 20
TOP_ROW_H = 46 # refresh button (28) + gap (4) + cooldown label (14)
TITLE_H = 30
PRICE_H = 18
STATUS_H = 20
DICE_H = 100
SPACING = 12
PLAY_BTN_H = 34
REFRESH_COOLDOWN = 60 # seconds
PRICE_CACHE_TTL = 15 * 60 # seconds; sale state changes too fast for longer caching
# Price format keys persisted in QSettings; combo display order matches.
PRICE_FORMAT_OPTIONS = [
("full", "$̶1̶9̶.̶9̶9̶ $9.99 (-50%)"),
("strike", "$̶1̶9̶.̶9̶9̶ $9.99"),
("final_pct", "$9.99 (-50%)"),
("final", "$9.99"),
]
# Window is ~40px wider than image+margin to fit a fourth control (Friends) in
# the top filter row alongside install/genre/tag combos.
WIN_W = IMG_W + MARGIN * 2 + 40
@@ -441,6 +452,37 @@ class FetchImageThread(QThread):
self.done.emit(QPixmap())
class FetchPriceThread(QThread):
# status: "priced" (data is price_overview dict), "free" (no price_overview but
# the app exists), or "unavailable" (lookup failed / app not on store).
done = pyqtSignal(int, str, dict) # appid, status, data
def __init__(self, appid):
super().__init__()
self.appid = appid
def run(self):
try:
url = (
"https://store.steampowered.com/api/appdetails"
f"?appids={self.appid}&filters=price_overview"
)
r = requests.get(url, timeout=10)
r.raise_for_status()
entry = r.json().get(str(self.appid), {})
if not entry.get("success"):
self.done.emit(self.appid, "unavailable", {})
return
data = entry.get("data") or {}
price = data.get("price_overview")
if price:
self.done.emit(self.appid, "priced", price)
else:
self.done.emit(self.appid, "free", {})
except Exception:
self.done.emit(self.appid, "unavailable", {})
class FetchGenresThread(QThread):
progress = pyqtSignal(int, int, dict) # done, total, cache snapshot
finished_ok = pyqtSignal(dict) # final cache
@@ -923,12 +965,20 @@ class SettingsDialog(QDialog):
key_help = QLabel(
'Get your free key at '
'<a href="https://steamcommunity.com/dev/apikey">steamcommunity.com/dev/apikey</a>.'
'<br>Log in with Steam, enter any domain name (e.g. <i>localhost</i>), and copy the key shown.'
'<a href="https://steamcommunity.com/dev/apikey">steamcommunity.com/dev/apikey</a>. '
'Log in with Steam, then copy the 32-character key shown after registering.'
'<br><br>'
'<b>Don\'t have a domain?</b> You don\'t need one. The <i>Domain Name</i> field '
'on Steam\'s page is <b>not validated</b> — type literally anything '
'(<i>localhost</i>, your name, <i>personal</i>) and click <b>Register</b>.'
)
key_help.setOpenExternalLinks(True)
key_help.setWordWrap(True)
key_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;")
key_help.setStyleSheet(
"color: #c7d5e0; font-size: 9pt; "
"background-color: #1f2a36; border: 1px solid #2a475e; border-radius: 4px; "
"padding: 8px 10px; margin-bottom: 10px;"
)
layout.addWidget(key_help)
# --- Steam ID ---
@@ -949,6 +999,37 @@ class SettingsDialog(QDialog):
id_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;")
layout.addWidget(id_help)
# --- Store price ---
layout.addWidget(QLabel("<b>Store price</b>"))
self.price_check = QCheckBox("Show current Steam store price under the title")
self.price_check.setChecked(settings.value("show_price", False, type=bool))
layout.addWidget(self.price_check)
fmt_row = QHBoxLayout()
fmt_row.setSpacing(6)
fmt_label = QLabel("Format:")
fmt_label.setStyleSheet("color: #8f98a0;")
fmt_row.addWidget(fmt_label)
self.price_format_combo = QComboBox()
for key, sample in PRICE_FORMAT_OPTIONS:
self.price_format_combo.addItem(sample, key)
saved_fmt = settings.value("price_format", "full")
idx = self.price_format_combo.findData(saved_fmt)
self.price_format_combo.setCurrentIndex(max(idx, 0))
fmt_row.addWidget(self.price_format_combo, 1)
layout.addLayout(fmt_row)
self.price_format_combo.setEnabled(self.price_check.isChecked())
self.price_check.toggled.connect(self.price_format_combo.setEnabled)
price_help = QLabel(
'Disabled by default. When on, fetches the public store price for the rolled game '
'(handy for spotting gift-able sales). Cached for 15 minutes per game.'
)
price_help.setWordWrap(True)
price_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;")
layout.addWidget(price_help)
# --- Buttons ---
buttons = QDialogButtonBox(
QDialogButtonBox.StandardButton.Save | QDialogButtonBox.StandardButton.Cancel
@@ -974,6 +1055,8 @@ class SettingsDialog(QDialog):
settings = QSettings("butter", "steam-dice")
settings.remove("api_key") # migrate away from plaintext storage
settings.setValue("steam_id", steam_id)
settings.setValue("show_price", self.price_check.isChecked())
settings.setValue("price_format", self.price_format_combo.currentData())
self.accept()
@@ -991,6 +1074,11 @@ class SteamDice(QMainWindow):
self.genres_thread = None
self.tags_table_thread = None
# Store-price feature: opt-in via settings; in-memory cache only.
# cache value: (timestamp, status, data) where status is "priced" / "free" / "unavailable".
self.price_thread = None
self.price_cache = {}
# Friends filter state. Friend list comes from disk; per-friend libraries
# populate lazily into self.friend_games as the user toggles them on.
self.friends = _load_friends_cache() # {sid: {"name": ...}}
@@ -1149,6 +1237,17 @@ class SteamDice(QMainWindow):
self.title_label.setVisible(False)
layout.addWidget(self.title_label)
# Store price (only visible when enabled in settings + value loaded)
self.price_label = QLabel()
self.price_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
self.price_label.setTextFormat(Qt.TextFormat.RichText)
price_font = QFont()
price_font.setPointSize(10)
self.price_label.setFont(price_font)
self.price_label.setFixedHeight(PRICE_H)
self.price_label.setVisible(False)
layout.addWidget(self.price_label)
# Game image
self.image_label = QLabel()
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
@@ -1157,12 +1256,13 @@ class SteamDice(QMainWindow):
self.image_label.setVisible(False)
layout.addWidget(self.image_label)
# Play button
self.play_btn = QPushButton(" Play")
self.play_btn.setIcon(QIcon.fromTheme("media-playback-start"))
# Action buttons: Play | Store Page
play_font = QFont()
play_font.setPointSize(11)
play_font.setBold(True)
self.play_btn = QPushButton(" Play")
self.play_btn.setIcon(QIcon.fromTheme("media-playback-start"))
self.play_btn.setFont(play_font)
self.play_btn.setFixedHeight(34)
self.play_btn.setCursor(Qt.CursorShape.PointingHandCursor)
@@ -1179,7 +1279,31 @@ class SteamDice(QMainWindow):
""")
self.play_btn.setVisible(False)
self.play_btn.clicked.connect(self._launch_game)
layout.addWidget(self.play_btn)
self.store_btn = QPushButton(" Store Page")
self.store_btn.setIcon(QIcon.fromTheme("applications-internet"))
self.store_btn.setFont(play_font)
self.store_btn.setFixedHeight(34)
self.store_btn.setCursor(Qt.CursorShape.PointingHandCursor)
self.store_btn.setStyleSheet("""
QPushButton {
background-color: #316282;
color: #c6d4df;
border: none;
border-radius: 4px;
padding: 0 16px;
}
QPushButton:hover { background-color: #4082a3; }
QPushButton:pressed { background-color: #1f4257; }
""")
self.store_btn.setVisible(False)
self.store_btn.clicked.connect(self._open_store_page)
action_row = QHBoxLayout()
action_row.setContentsMargins(0, 0, 0, 0)
action_row.addWidget(self.play_btn)
action_row.addWidget(self.store_btn)
layout.addLayout(action_row)
# Status / loading text
self.status_label = QLabel("Loading library…")
@@ -1244,10 +1368,12 @@ class SteamDice(QMainWindow):
self.fetch_thread.start()
def _open_settings(self):
old_steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
s = QSettings("butter", "steam-dice")
old_steam_id = s.value("steam_id", "")
dlg = SettingsDialog(self)
if dlg.exec() == QDialog.DialogCode.Accepted:
new_steam_id = QSettings("butter", "steam-dice").value("steam_id", "")
s = QSettings("butter", "steam-dice")
new_steam_id = s.value("steam_id", "")
if old_steam_id and new_steam_id != old_steam_id:
# Different account — friend list belongs to the previous user.
# Clear in-memory state; the next click on Friends will re-fetch.
@@ -1256,6 +1382,14 @@ class SteamDice(QMainWindow):
self.friend_games = {}
self.friend_status = {}
self.friends_btn.clear()
# Sync price label visibility to the current setting state.
if not s.value("show_price", False, type=bool):
self.price_label.clear()
self.price_label.setVisible(False)
elif self.current_appid is not None:
self._maybe_fetch_price(self.current_appid)
self.status_label.setText("Loading library…")
self.dice_btn.setEnabled(False)
self.filter_combo.setEnabled(False)
@@ -1651,6 +1785,9 @@ class SteamDice(QMainWindow):
self.image_label.setText("Loading…")
self.image_label.setVisible(True)
self.play_btn.setVisible(False)
self.store_btn.setVisible(False)
self.price_label.clear()
self.price_label.setVisible(False)
self.status_label.setText("")
if self.image_thread is not None:
@@ -1659,9 +1796,12 @@ class SteamDice(QMainWindow):
self.image_thread.done.connect(self._on_image_loaded)
self.image_thread.start()
self._maybe_fetch_price(game["appid"])
def _on_image_loaded(self, pixmap):
self.dice_btn.setEnabled(True)
self.play_btn.setVisible(True)
self.store_btn.setVisible(True)
if pixmap.isNull():
self.image_label.setText("No image available")
else:
@@ -1677,6 +1817,73 @@ class SteamDice(QMainWindow):
if self.current_appid is not None:
subprocess.Popen(["xdg-open", f"steam://rungameid/{self.current_appid}"])
def _open_store_page(self):
if self.current_appid is not None:
subprocess.Popen(["xdg-open", f"https://store.steampowered.com/app/{self.current_appid}/"])
def _maybe_fetch_price(self, appid):
s = QSettings("butter", "steam-dice")
if not s.value("show_price", False, type=bool):
return
cached = self.price_cache.get(appid)
if cached and time.time() - cached[0] < PRICE_CACHE_TTL:
self._render_price(appid, cached[1], cached[2])
return
if self.price_thread is not None and self.price_thread.isRunning():
try:
self.price_thread.done.disconnect()
except TypeError:
pass
self.price_thread = FetchPriceThread(appid)
self.price_thread.done.connect(self._on_price_loaded)
self.price_thread.start()
def _on_price_loaded(self, appid, status, data):
self.price_cache[appid] = (time.time(), status, data)
self._render_price(appid, status, data)
def _render_price(self, appid, status, data):
# Race protection: another roll happened while we were fetching.
if appid != self.current_appid:
return
s = QSettings("butter", "steam-dice")
if not s.value("show_price", False, type=bool):
return
fmt = s.value("price_format", "full")
text = self._format_price(status, data, fmt)
self.price_label.setText(text)
self.price_label.setVisible(True)
@staticmethod
def _format_price(status, data, style):
if status == "free":
return '<span style="color: #8f98a0;">Free</span>'
if status == "unavailable" or not data:
return '<span style="color: #8f98a0;">—</span>'
final = data.get("final_formatted") or ""
initial = data.get("initial_formatted") or ""
pct = data.get("discount_percent") or 0
on_sale = pct > 0 and initial and initial != final
if not on_sale:
return f'<span style="color: #c7d5e0;">{final}</span>'
strike = f'<s style="color: #6b7884;">{initial}</s>'
sale = f'<span style="color: #a4d007;">{final}</span>'
pct_part = f'<span style="color: #a4d007;"> (-{pct}%)</span>'
if style == "final":
return sale
if style == "final_pct":
return f"{sale}{pct_part}"
if style == "strike":
return f"{strike} &nbsp; {sale}"
# "full" (default)
return f"{strike} &nbsp; {sale}{pct_part}"
def closeEvent(self, a0):
if self.genres_thread and self.genres_thread.isRunning():
self.genres_thread.stop()
@@ -1688,6 +1895,8 @@ class SteamDice(QMainWindow):
if self.friend_games_thread and self.friend_games_thread.isRunning():
self.friend_games_thread.stop()
self.friend_games_thread.wait(15000)
if self.price_thread and self.price_thread.isRunning():
self.price_thread.wait(15000)
super().closeEvent(a0)