11 Commits

Author SHA1 Message Date
Renovate Bot
8849e90697 fix(deps): lock file maintenance python dependencies
Some checks failed
renovate/artifacts Artifact file update failure
Code check / Check code (pull_request) Successful in 1m34s
2025-08-14 17:26:08 +00:00
ac20447ba3 chore(renovate): skip broken packages
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:19:25 +05:00
ba143c15a8 chore(renovate): added uv to container
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:02:04 +05:00
13068f3959 chore(renovate): fix work with uv
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 22:02:04 +05:00
Alex Smith
c8360d08ca fix(downloader): Clear cache entry for non-existent file 2025-08-14 21:42:18 +05:00
b070ff1fca fix(animations): fix all Qpainter conflicts
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-14 13:14:28 +05:00
b5a2f41bdf chore(pre-commit): update all hooks
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 13:11:33 +05:00
9a37f31841 chore(renovate): use config from remote repo
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:55:51 +05:00
aeed0112cd chore(renovate): use latest container allways
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:52:13 +05:00
027ae68d4d chore(renovate): added pre-commit
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-13 12:50:39 +05:00
37d41fef8d feat: use cef on EGS too
All checks were successful
Code check / Check code (push) Successful in 1m34s
renovate / renovate (push) Successful in 57s
Signed-off-by: Boris Yumankulov <boria138@altlinux.org>
2025-08-10 13:02:39 +05:00
9 changed files with 273 additions and 120 deletions

View File

@@ -8,11 +8,24 @@ on:
jobs: jobs:
renovate: renovate:
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: ghcr.io/renovatebot/renovate:41.1.4 container: ghcr.io/renovatebot/renovate:latest
steps: steps:
- uses: https://gitea.com/actions/checkout@v4 - uses: https://gitea.com/actions/checkout@v4
- run: renovate
- name: Install uv
uses: https://github.com/astral-sh/setup-uv@v6
with:
enable-cache: true
- name: Download external renovate config
run: |
mkdir -p /tmp/renovate-config
curl -fsSL "https://git.linux-gaming.ru/Linux-Gaming/renovate-config/raw/branch/main/config.js" \
-o /tmp/renovate-config/config.js
- name: Run Renovate
run: renovate
env: env:
RENOVATE_CONFIG_FILE: "/workspace/Boria138/PortProtonQt/config.js" RENOVATE_CONFIG_FILE: "/tmp/renovate-config/config.js"
LOG_LEVEL: "debug" LOG_LEVEL: "debug"
RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }} RENOVATE_TOKEN: ${{ secrets.RENOVATE_TOKEN }}

View File

@@ -3,7 +3,7 @@
exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)' exclude: '(data/|documentation/|portprotonqt/locales/|portprotonqt/custom_data/|dev-scripts/|\.venv/|venv/|.*\.svg$)'
repos: repos:
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v6.0.0
hooks: hooks:
- id: trailing-whitespace - id: trailing-whitespace
- id: end-of-file-fixer - id: end-of-file-fixer
@@ -11,15 +11,14 @@ repos:
- id: check-yaml - id: check-yaml
- repo: https://github.com/astral-sh/uv-pre-commit - repo: https://github.com/astral-sh/uv-pre-commit
rev: 0.6.14 rev: 0.8.9
hooks: hooks:
- id: uv-lock - id: uv-lock
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.11.5 rev: v0.12.8
hooks: hooks:
- id: ruff - id: ruff-check
args: [--fix]
- repo: local - repo: local
hooks: hooks:

View File

@@ -1,8 +0,0 @@
module.exports = {
"endpoint": "https://git.linux-gaming.ru/api/v1",
"gitAuthor": "Renovate Bot <noreply@linux-gaming.ru>",
"platform": "gitea",
"onboardingConfigFileName": "renovate.json",
"autodiscover": true,
"optimizeForDisabled": true,
};

View File

@@ -7,6 +7,19 @@ from portprotonqt.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
class SafeOpacityEffect(QGraphicsOpacityEffect):
def __init__(self, parent=None, disable_at_full=True):
super().__init__(parent)
self.disable_at_full = disable_at_full
def setOpacity(self, opacity: float):
opacity = max(0.0, min(1.0, opacity))
super().setOpacity(opacity)
if opacity < 1.0:
self.setEnabled(True)
elif self.disable_at_full:
self.setEnabled(False)
class GameCardAnimations: class GameCardAnimations:
def __init__(self, game_card, theme=None): def __init__(self, game_card, theme=None):
self.game_card = game_card self.game_card = game_card
@@ -138,7 +151,9 @@ class GameCardAnimations:
self.thickness_anim.start() self.thickness_anim.start()
def paint_border(self, painter: QPainter): def paint_border(self, painter: QPainter):
"""Paint the animated border for the GameCard.""" if not painter.isActive():
logger.warning("Painter is not active; skipping border paint")
return
painter.setRenderHint(QPainter.RenderHint.Antialiasing) painter.setRenderHint(QPainter.RenderHint.Antialiasing)
pen = QPen() pen = QPen()
pen.setWidth(self.game_card._borderWidth) pen.setWidth(self.game_card._borderWidth)
@@ -154,6 +169,8 @@ class GameCardAnimations:
radius = 18 radius = 18
bw = round(self.game_card._borderWidth / 2) bw = round(self.game_card._borderWidth / 2)
rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw) rect = self.game_card.rect().adjusted(bw, bw, -bw, -bw)
if rect.isEmpty():
return # Avoid drawing invalid rect
painter.drawRoundedRect(rect, radius, radius) painter.drawRoundedRect(rect, radius, radius)
class DetailPageAnimations: class DetailPageAnimations:
@@ -164,21 +181,28 @@ class DetailPageAnimations:
def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable): def animate_detail_page(self, detail_page: QWidget, load_image_and_restore_effect: Callable, cleanup_animation: Callable):
"""Animate the detail page based on theme settings.""" """Animate the detail page based on theme settings."""
shadow = detail_page.graphicsEffect()
animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade") animation_type = self.theme.GAME_CARD_ANIMATION.get("detail_page_animation_type", "fade")
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration", 350)
if animation_type == "fade": if animation_type == "fade":
opacity_effect = QGraphicsOpacityEffect(detail_page) original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=True)
opacity_effect.setOpacity(0.0)
detail_page.setGraphicsEffect(opacity_effect) detail_page.setGraphicsEffect(opacity_effect)
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity")) animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
animation.setDuration(duration) animation.setDuration(duration)
animation.setStartValue(0) animation.setStartValue(0.0)
animation.setEndValue(1) animation.setEndValue(0.999)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation self.animations[detail_page] = animation
animation.finished.connect(lambda: detail_page.setGraphicsEffect(shadow) if shadow is not None else detail_page.setGraphicsEffect(None)) # type: ignore def restore_effect():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError:
logger.debug("Original effect already deleted")
animation.finished.connect(restore_effect)
animation.finished.connect(load_image_and_restore_effect) animation.finished.connect(load_image_and_restore_effect)
animation.finished.connect(opacity_effect.deleteLater)
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve", "OutCubic")])
@@ -243,15 +267,24 @@ class DetailPageAnimations:
# Define animation based on type # Define animation based on type
if animation_type == "fade": if animation_type == "fade":
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_fade_duration_exit", 350)
opacity_effect = QGraphicsOpacityEffect(detail_page) original_effect = detail_page.graphicsEffect()
opacity_effect = SafeOpacityEffect(detail_page, disable_at_full=False)
opacity_effect.setOpacity(0.999)
detail_page.setGraphicsEffect(opacity_effect) detail_page.setGraphicsEffect(opacity_effect)
animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity")) animation = QPropertyAnimation(opacity_effect, QByteArray(b"opacity"))
animation.setDuration(duration) animation.setDuration(duration)
animation.setStartValue(1) animation.setStartValue(0.999)
animation.setEndValue(0) animation.setEndValue(0.0)
animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped) animation.start(QAbstractAnimation.DeletionPolicy.DeleteWhenStopped)
self.animations[detail_page] = animation self.animations[detail_page] = animation
animation.finished.connect(cleanup_callback) def restore_and_cleanup():
try:
detail_page.setGraphicsEffect(original_effect) # type: ignore
except RuntimeError:
logger.debug("Original effect already deleted")
cleanup_callback()
animation.finished.connect(restore_and_cleanup)
animation.finished.connect(opacity_effect.deleteLater) # Clean up effect
elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]: elif animation_type in ["slide_left", "slide_right", "slide_up", "slide_down"]:
duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500) duration = self.theme.GAME_CARD_ANIMATION.get("detail_page_slide_duration_exit", 500)
easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")]) easing_curve = QEasingCurve(QEasingCurve.Type[self.theme.GAME_CARD_ANIMATION.get("detail_page_easing_curve_exit", "InCubic")])

View File

@@ -144,14 +144,21 @@ class Downloader(QObject):
logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем") logger.warning(f"Предыдущая ошибка загрузки для {url}, пропускаем")
return None return None
if url in self._cache: if url in self._cache:
return self._cache[url] cached_path = self._cache[url]
if os.path.exists(cached_path):
if os.path.abspath(cached_path) == os.path.abspath(local_path):
return cached_path
else:
del self._cache[url]
url_lock = self._get_url_lock(url) url_lock = self._get_url_lock(url)
with url_lock: with url_lock:
with self._global_lock: with self._global_lock:
if url in self._last_error: if url in self._last_error:
return None return None
if url in self._cache: if url in self._cache:
return self._cache[url] cached_path = self._cache[url]
if os.path.exists(cached_path) and os.path.abspath(cached_path) == os.path.abspath(local_path):
return cached_path
result = download_with_cache(url, local_path, timeout, self) result = download_with_cache(url, local_path, timeout, self)
with self._global_lock: with self._global_lock:
if result: if result:

View File

@@ -16,13 +16,14 @@ from portprotonqt.time_utils import parse_playtime_file, format_playtime, get_la
from portprotonqt.config_utils import get_portproton_location from portprotonqt.config_utils import get_portproton_location
from portprotonqt.steam_api import ( from portprotonqt.steam_api import (
get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async, get_weanticheatyet_status_async, get_steam_apps_and_index_async, get_protondb_tier_async,
search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail search_app, get_steam_home, get_last_steam_user, convert_steam_id, generate_thumbnail, call_steam_api
) )
import vdf import vdf
import shutil import shutil
import zlib import zlib
from portprotonqt.downloader import Downloader from portprotonqt.downloader import Downloader
from PySide6.QtGui import QPixmap from PySide6.QtGui import QPixmap
import base64
logger = get_logger(__name__) logger = get_logger(__name__)
downloader = Downloader() downloader = Downloader()
@@ -66,7 +67,8 @@ def get_cache_dir() -> Path:
def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None: def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callable[[tuple[bool, str]], None]) -> None:
""" """
Removes an EGS game from Steam by modifying shortcuts.vdf and deleting the launch script. Removes an EGS game from Steam using CEF API or by modifying shortcuts.vdf and deleting the launch script.
Also deletes associated cover files in the Steam grid directory.
Calls the callback with (success, message). Calls the callback with (success, message).
Args: Args:
@@ -74,6 +76,7 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
portproton_dir: Path to the PortProton directory. portproton_dir: Path to the PortProton directory.
callback: Callback function to handle the result (success, message). callback: Callback function to handle the result (success, message).
""" """
if not portproton_dir: if not portproton_dir:
logger.error("PortProton directory not found") logger.error("PortProton directory not found")
callback((False, "PortProton directory not found")) callback((False, "PortProton directory not found"))
@@ -101,51 +104,89 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
unsigned_id = convert_steam_id(user_id) unsigned_id = convert_steam_id(user_id)
user_dir = os.path.join(userdata_dir, str(unsigned_id)) user_dir = os.path.join(userdata_dir, str(unsigned_id))
steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf") steam_shortcuts_path = os.path.join(user_dir, "config", "shortcuts.vdf")
backup_path = f"{steam_shortcuts_path}.backup" grid_dir = os.path.join(user_dir, "config", "grid")
if not os.path.exists(steam_shortcuts_path): if not os.path.exists(steam_shortcuts_path):
logger.error("Steam shortcuts file not found") logger.error("Steam shortcuts file not found")
callback((False, "Steam shortcuts file not found")) callback((False, "Steam shortcuts file not found"))
return return
# Find appid for the shortcut
try:
with open(steam_shortcuts_path, 'rb') as f:
shortcuts_data = vdf.binary_load(f)
shortcuts = shortcuts_data.get("shortcuts", {})
appid = None
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
appid = convert_steam_id(int(entry.get("appid")))
logger.info(f"Found matching shortcut for '{game_name}' with AppID {appid}")
break
if not appid:
logger.info(f"Game '{game_name}' not found in Steam shortcuts")
callback((False, f"Game '{game_name}' not found in Steam"))
return
except Exception as e:
logger.error(f"Failed to load shortcuts.vdf: {e}")
callback((False, f"Failed to load shortcuts.vdf: {e}"))
return
# Try CEF API first
logger.info(f"Attempting to remove EGS game '{game_name}' via Steam CEF API with AppID {appid}")
api_response = call_steam_api("removeShortcut", appid)
if api_response is not None: # API responded, even if empty
logger.info(f"Shortcut for AppID {appid} successfully removed via CEF API")
# Delete cover files
cover_files = [
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
try:
os.remove(cover_file)
logger.info(f"Deleted cover file: {cover_file}")
except Exception as e:
logger.error(f"Failed to delete cover file {cover_file}: {e}")
# Delete launch script
if os.path.exists(script_path):
try:
os.remove(script_path)
logger.info(f"Removed EGS script: {script_path}")
except OSError as e:
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
callback((True, f"Game '{game_name}' was removed from Steam. Please restart Steam for changes to take effect."))
return
# Fallback to VDF modification
logger.warning("CEF API failed for EGS game removal; falling back to VDF modification")
backup_path = f"{steam_shortcuts_path}.backup"
try: try:
shutil.copy2(steam_shortcuts_path, backup_path) shutil.copy2(steam_shortcuts_path, backup_path)
logger.info("Created backup of shortcuts.vdf at %s", backup_path) logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e: except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}") logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
return return
try: try:
with open(steam_shortcuts_path, 'rb') as f: new_shortcuts = {}
shortcuts_data = vdf.binary_load(f) index = 0
except Exception as e: for _key, entry in shortcuts.items():
logger.error(f"Failed to load shortcuts.vdf: {e}") if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
callback((False, f"Failed to load shortcuts.vdf: {e}")) logger.info(f"Removing EGS game '{game_name}' from Steam shortcuts")
return continue
new_shortcuts[str(index)] = entry
index += 1
shortcuts = shortcuts_data.get("shortcuts", {})
modified = False
new_shortcuts = {}
index = 0
for _key, entry in shortcuts.items():
if entry.get("AppName") == game_name and entry.get("Exe") == quoted_script_path:
modified = True
logger.info("Removing EGS game '%s' from Steam shortcuts", game_name)
continue
new_shortcuts[str(index)] = entry
index += 1
if not modified:
logger.error("Game '%s' not found in Steam shortcuts", game_name)
callback((False, f"Game '{game_name}' not found in Steam shortcuts"))
return
try:
with open(steam_shortcuts_path, 'wb') as f: with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": new_shortcuts}, f) vdf.binary_dump({"shortcuts": new_shortcuts}, f)
logger.info("Updated shortcuts.vdf, removed '%s'", game_name) logger.info(f"Updated shortcuts.vdf, removed '{game_name}'")
except Exception as e: except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -157,10 +198,26 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
callback((False, f"Failed to update shortcuts.vdf: {e}")) callback((False, f"Failed to update shortcuts.vdf: {e}"))
return return
# Delete cover files
cover_files = [
os.path.join(grid_dir, f"{appid}.jpg"),
os.path.join(grid_dir, f"{appid}p.jpg"),
os.path.join(grid_dir, f"{appid}_hero.jpg"),
os.path.join(grid_dir, f"{appid}_logo.png")
]
for cover_file in cover_files:
if os.path.exists(cover_file):
try:
os.remove(cover_file)
logger.info(f"Deleted cover file: {cover_file}")
except Exception as e:
logger.error(f"Failed to delete cover file {cover_file}: {e}")
# Delete launch script
if os.path.exists(script_path): if os.path.exists(script_path):
try: try:
os.remove(script_path) os.remove(script_path)
logger.info("Removed EGS script: %s", script_path) logger.info(f"Removed EGS script: {script_path}")
except OSError as e: except OSError as e:
logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}") logger.warning(f"Failed to remove EGS script '{script_path}': {str(e)}")
@@ -168,11 +225,17 @@ def remove_egs_from_steam(game_name: str, portproton_dir: str, callback: Callabl
def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None: def add_egs_to_steam(app_name: str, game_title: str, legendary_path: str, callback: Callable[[tuple[bool, str]], None]) -> None:
""" """
Asynchronously adds an EGS game to Steam via shortcuts.vdf with PortProton tag. Asynchronously adds an EGS game to Steam via CEF API or shortcuts.vdf with PortProton tag.
Creates a launch script using legendary CLI with --no-wine and PortProton wrapper. Creates a launch script using legendary CLI with --no-wine and PortProton wrapper.
Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh. Wrapper is flatpak run if portproton_location is None or contains .var, otherwise start.sh.
Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail. Downloads Steam Grid covers if the game exists in Steam, and generates a thumbnail.
Calls the callback with (success, message). Calls the callback with (success, message).
Args:
app_name: The Legendary app_name (unique identifier for the game).
game_title: The display name of the game.
legendary_path: Path to the Legendary CLI executable.
callback: Callback function to handle the result (success, message).
""" """
if not app_name or not app_name.strip() or not game_title or not game_title.strip(): if not app_name or not app_name.strip() or not game_title or not game_title.strip():
logger.error("Invalid app_name or game_title: empty or whitespace") logger.error("Invalid app_name or game_title: empty or whitespace")
@@ -267,47 +330,47 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
grid_dir = user_dir / "config" / "grid" grid_dir = user_dir / "config" / "grid"
os.makedirs(grid_dir, exist_ok=True) os.makedirs(grid_dir, exist_ok=True)
# Backup shortcuts.vdf # Try CEF API first
backup_path = f"{steam_shortcuts_path}.backup" logger.info(f"Attempting to add EGS game '{game_title}' via Steam CEF API")
if os.path.exists(steam_shortcuts_path): api_response = call_steam_api(
try: "createShortcut",
shutil.copy2(steam_shortcuts_path, backup_path) game_title,
logger.info(f"Created backup of shortcuts.vdf at {backup_path}") script_path,
except Exception as e: str(Path(script_path).parent),
logger.error(f"Failed to create backup of shortcuts.vdf: {e}") icon_path,
callback((False, f"Failed to create backup of shortcuts.vdf: {e}")) ""
return )
# Generate unique appid appid = None
unique_string = f"{script_path}{game_title}" was_api_used = False
baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
appid = baseid | 0x80000000 if api_response and isinstance(api_response, dict) and 'id' in api_response:
if appid > 0x7FFFFFFF: appid = api_response['id']
aidvdf = appid - 0x100000000 was_api_used = True
logger.info(f"EGS game '{game_title}' successfully added via CEF API. AppID: {appid}")
else: else:
aidvdf = appid logger.warning("CEF API failed for EGS game addition; falling back to VDF modification")
# Backup shortcuts.vdf
backup_path = f"{steam_shortcuts_path}.backup"
if os.path.exists(steam_shortcuts_path):
try:
shutil.copy2(steam_shortcuts_path, backup_path)
logger.info(f"Created backup of shortcuts.vdf at {backup_path}")
except Exception as e:
logger.error(f"Failed to create backup of shortcuts.vdf: {e}")
callback((False, f"Failed to create backup of shortcuts.vdf: {e}"))
return
steam_appid = None # Generate unique appid
downloaded_count = 0 unique_string = f"{script_path}{game_title}"
total_covers = 4 baseid = zlib.crc32(unique_string.encode('utf-8')) & 0xffffffff
download_lock = threading.Lock() appid = baseid | 0x80000000
if appid > 0x7FFFFFFF:
aidvdf = appid - 0x100000000
else:
aidvdf = appid
def on_cover_download(cover_file: str, cover_type: str): # Create shortcut entry
nonlocal downloaded_count
try:
if cover_file and os.path.exists(cover_file):
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
else:
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
finalize_shortcut()
def finalize_shortcut():
tags_dict = {'0': 'PortProton'}
shortcut = { shortcut = {
"appid": aidvdf, "appid": aidvdf,
"AppName": game_title, "AppName": game_title,
@@ -322,7 +385,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
"Devkit": 0, "Devkit": 0,
"DevkitGameID": "", "DevkitGameID": "",
"LastPlayTime": 0, "LastPlayTime": 0,
"tags": tags_dict "tags": {'0': 'PortProton'}
} }
logger.info(f"Shortcut entry for EGS game: {shortcut}") logger.info(f"Shortcut entry for EGS game: {shortcut}")
@@ -353,6 +416,7 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
with open(steam_shortcuts_path, 'wb') as f: with open(steam_shortcuts_path, 'wb') as f:
vdf.binary_dump({"shortcuts": shortcuts}, f) vdf.binary_dump({"shortcuts": shortcuts}, f)
logger.info(f"EGS game '{game_title}' added to Steam via VDF")
except Exception as e: except Exception as e:
logger.error(f"Failed to update shortcuts.vdf: {e}") logger.error(f"Failed to update shortcuts.vdf: {e}")
if os.path.exists(backup_path): if os.path.exists(backup_path):
@@ -364,8 +428,42 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
callback((False, f"Failed to update shortcuts.vdf: {e}")) callback((False, f"Failed to update shortcuts.vdf: {e}"))
return return
logger.info(f"EGS game '{game_title}' added to Steam") if not appid:
callback((True, f"Game '{game_title}' added to Steam with covers")) callback((False, "Failed to create shortcut via any method"))
return
steam_appid = None
downloaded_count = 0
total_covers = 4
download_lock = threading.Lock()
def on_cover_download(cover_file: str | None, cover_type: str, index: int):
nonlocal downloaded_count
try:
if cover_file is None or not os.path.exists(cover_file):
logger.warning(f"Failed to download cover {cover_type} for appid {steam_appid}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
callback((True, f"Game '{game_title}' added to Steam with covers"))
return
logger.info(f"Downloaded cover {cover_type} to {cover_file}")
if was_api_used:
try:
with open(cover_file, 'rb') as f:
img_b64 = base64.b64encode(f.read()).decode('utf-8')
logger.info(f"Applying cover type '{cover_type}' via API for AppID {appid}")
ext = Path(cover_type).suffix.lstrip('.')
call_steam_api("setGrid", appid, index, ext, img_b64)
except Exception as e:
logger.error(f"Error applying cover '{cover_type}' via API: {e}")
except Exception as e:
logger.error(f"Error processing cover {cover_type} for appid {steam_appid}: {e}")
with download_lock:
downloaded_count += 1
if downloaded_count == total_covers:
callback((True, f"Game '{game_title}' added to Steam with covers"))
def on_steam_apps(steam_data: tuple[list, dict]): def on_steam_apps(steam_data: tuple[list, dict]):
nonlocal steam_appid nonlocal steam_appid
@@ -375,24 +473,24 @@ export LEGENDARY_CONFIG_PATH="{legendary_config_path}"
if not steam_appid: if not steam_appid:
logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download") logger.info(f"No Steam appid found for EGS game {game_title}, skipping cover download")
finalize_shortcut() callback((True, f"Game '{game_title}' added to Steam"))
return return
cover_types = [ cover_types = [
(".jpg", "header.jpg"), (".jpg", "header.jpg", 0),
("p.jpg", "library_600x900_2x.jpg"), ("p.jpg", "library_600x900_2x.jpg", 1),
("_hero.jpg", "library_hero.jpg"), ("_hero.jpg", "library_hero.jpg", 2),
("_logo.png", "logo.png") ("_logo.png", "logo.png", 3)
] ]
for suffix, cover_type in cover_types: for suffix, cover_type, index in cover_types:
cover_file = os.path.join(grid_dir, f"{appid}{suffix}") cover_file = os.path.join(grid_dir, f"{appid}{suffix}")
cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}" cover_url = f"https://cdn.cloudflare.steamstatic.com/steam/apps/{steam_appid}/{cover_type}"
downloader.download_async( downloader.download_async(
cover_url, cover_url,
cover_file, cover_file,
timeout=5, timeout=5,
callback=lambda result, cfile=cover_file, ctype=cover_type: on_cover_download(cfile, ctype) callback=lambda result, ctype=cover_type, idx=index: on_cover_download(result, ctype, idx)
) )
get_steam_apps_and_index_async(on_steam_apps) get_steam_apps_and_index_async(on_steam_apps)

View File

@@ -28,15 +28,15 @@ requires-python = ">=3.10"
dependencies = [ dependencies = [
"babel>=2.17.0", "babel>=2.17.0",
"beautifulsoup4>=4.13.4", "beautifulsoup4>=4.13.4",
"evdev>=1.9.1", "evdev>=1.9.2",
"icoextract>=0.1.6", "icoextract>=0.2.0",
"numpy>=2.2.4", "numpy>=2.2.4",
"orjson>=3.10.16", "orjson>=3.11.2",
"pillow>=11.2.1", "pillow>=11.3.0",
"psutil>=7.0.0", "psutil>=7.0.0",
"pyside6>=6.9.0", "pyside6>=6.9.1",
"pyudev>=0.24.3", "pyudev>=0.24.3",
"requests>=2.32.3", "requests>=2.32.4",
"tqdm>=4.67.1", "tqdm>=4.67.1",
"vdf>=3.4", "vdf>=3.4",
"websocket-client>=1.8.0", "websocket-client>=1.8.0",
@@ -103,7 +103,7 @@ ignore = [
[dependency-groups] [dependency-groups]
dev = [ dev = [
"pre-commit>=4.2.0", "pre-commit>=4.3.0",
"pyaspeller>=2.0.2", "pyaspeller>=2.0.2",
"pyright>=1.1.400", "pyright>=1.1.403",
] ]

View File

@@ -15,12 +15,23 @@
"enabled": false "enabled": false
}, },
{ {
"matchFileNames": [".gitea/workflows/**.yaml", ".gitea/workflows/**.yml"], "matchFileNames": [".python-version"],
"enabled": false "enabled": false
}, },
{ {
"matchFileNames": [".python-version"], "matchManagers": ["github-actions", "pre-commit"],
"enabled": false "enabled": false
},
{
"matchManagers": ["pep621"],
"rangeStrategy": "bump",
"versioning": "pep440",
"groupName": "Python dependencies"
},
{
"matchPackageNames": ["numpy", "setuptools"],
"enabled": false,
"description": "Disabled due to Python 3.10 incompatibility with numpy>=2.3.2 (requires Python>=3.11)"
} }
] ]
} }

6
uv.lock generated
View File

@@ -562,15 +562,15 @@ wheels = [
[[package]] [[package]]
name = "pyright" name = "pyright"
version = "1.1.402" version = "1.1.403"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "nodeenv" }, { name = "nodeenv" },
{ name = "typing-extensions" }, { name = "typing-extensions" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/aa/04/ce0c132d00e20f2d2fb3b3e7c125264ca8b909e693841210534b1ea1752f/pyright-1.1.402.tar.gz", hash = "sha256:85a33c2d40cd4439c66aa946fd4ce71ab2f3f5b8c22ce36a623f59ac22937683", size = 3888207 } sdist = { url = "https://files.pythonhosted.org/packages/fe/f6/35f885264ff08c960b23d1542038d8da86971c5d8c955cfab195a4f672d7/pyright-1.1.403.tar.gz", hash = "sha256:3ab69b9f41c67fb5bbb4d7a36243256f0d549ed3608678d381d5f51863921104", size = 3913526 }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/37/1a1c62d955e82adae588be8e374c7f77b165b6cb4203f7d581269959abbc/pyright-1.1.402-py3-none-any.whl", hash = "sha256:2c721f11869baac1884e846232800fe021c33f1b4acb3929cff321f7ea4e2982", size = 5624004 }, { url = "https://files.pythonhosted.org/packages/49/b6/b04e5c2f41a5ccad74a1a4759da41adb20b4bc9d59a5e08d29ba60084d07/pyright-1.1.403-py3-none-any.whl", hash = "sha256:c0eeca5aa76cbef3fcc271259bbd785753c7ad7bcac99a9162b4c4c7daed23b3", size = 5684504 },
] ]
[[package]] [[package]]