Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4494fe979c | |||
| 3e5a554a59 | |||
| dc69aa7d30 | |||
| f8f8e9ab18 | |||
| e28646ad1a | |||
| 7b5e777277 | |||
| a7db5c8bc0 | |||
| 15ff3527e8 | |||
| 052b6d43c5 | |||
| 47abb10d6e | |||
| 3b852d0f5a | |||
| 3e84c3a210 | |||
| 2221636e18 | |||
| b43eb11924 |
@@ -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')
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||

|
||||
|
||||
## 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
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 60 KiB |
+221
-12
@@ -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} {sale}"
|
||||
# "full" (default)
|
||||
return f"{strike} {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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user