diff --git a/steam_dice.py b/steam_dice.py index 7973c7d..2b2bae1 100755 --- a/steam_dice.py +++ b/steam_dice.py @@ -4,6 +4,7 @@ import json import os import re import sys +import time import random import subprocess import requests @@ -13,8 +14,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, - 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 @@ -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 @@ -949,6 +991,37 @@ class SettingsDialog(QDialog): id_help.setStyleSheet("color: #8f98a0; font-size: 9pt; padding-bottom: 10px;") layout.addWidget(id_help) + # --- Store price --- + layout.addWidget(QLabel("Store price")) + 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 +1047,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 +1066,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 +1229,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) @@ -1269,10 +1360,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. @@ -1281,6 +1374,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) @@ -1677,6 +1778,8 @@ class SteamDice(QMainWindow): 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: @@ -1685,6 +1788,8 @@ 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) @@ -1708,6 +1813,69 @@ class SteamDice(QMainWindow): 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 'Free' + if status == "unavailable" or not data: + return '' + + 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'{final}' + + strike = f'{initial}' + sale = f'{final}' + pct_part = f' (-{pct}%)' + + 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() @@ -1719,6 +1887,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)