From 45be2eb16579f7a3002a5672f5ff2bbf26095b58 Mon Sep 17 00:00:00 2001 From: kramo <93832451+kra-mo@users.noreply.github.com> Date: Tue, 4 Apr 2023 17:35:36 +0200 Subject: [PATCH] Basic SteamGridDB support --- data/gtk/preferences.blp | 34 ++++++++++++++ data/hu.kramo.Cartridges.gschema.xml | 9 ++++ hu.kramo.Cartridges.json | 39 ++++++++++++++++ src/meson.build | 1 + src/preferences.py | 26 +++++++++++ src/utils/bottles_parser.py | 22 ++++----- src/utils/create_details_window.py | 8 ++-- src/utils/heroic_parser.py | 14 ++---- src/utils/importer.py | 59 ++++++++++++++++++------ src/utils/itch_parser.py | 12 +++-- src/utils/lutris_parser.py | 8 +--- src/utils/save_cover.py | 2 +- src/utils/steam_parser.py | 21 +++------ src/utils/steamgriddb.py | 69 ++++++++++++++++++++++++++++ 14 files changed, 257 insertions(+), 67 deletions(-) create mode 100644 src/utils/steamgriddb.py diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index 5a87688..fffb5b9 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -202,4 +202,38 @@ template PreferencesWindow : Adw.PreferencesWindow { } } } + + Adw.PreferencesPage sgdb_page { + name: "sgdb"; + title: _("SteamGridDB"); + icon-name: "image-x-generic-symbolic"; + + Adw.PreferencesGroup sgdb_key_group { + title: _("Authentication"); + description: _("An API Key is required to use SteamGridDB. You can generate one here."); + + Adw.EntryRow sgdb_key_entry_row { + title: _("API Key"); + } + } + + Adw.PreferencesGroup sgdb_behavior_group { + title: _("Behavior"); + + Adw.ActionRow { + title: _("Download Images on Import"); + + Switch sgdb_download_switch { + valign: center; + } + } + Adw.ActionRow { + title: _("Prefer Over Official Images"); + + Switch sgdb_prefer_switch { + valign: center; + } + } + } + } } diff --git a/data/hu.kramo.Cartridges.gschema.xml b/data/hu.kramo.Cartridges.gschema.xml index d05ef97..e4363ac 100644 --- a/data/hu.kramo.Cartridges.gschema.xml +++ b/data/hu.kramo.Cartridges.gschema.xml @@ -57,6 +57,15 @@ "~/.var/app/io.itch.itch/config/itch/" + + + "" + + + false + + + false diff --git a/hu.kramo.Cartridges.json b/hu.kramo.Cartridges.json index 1182fcb..c36f1e1 100644 --- a/hu.kramo.Cartridges.json +++ b/hu.kramo.Cartridges.json @@ -63,6 +63,45 @@ "*" ] }, + { + "name": "python3-python-steamgriddb", + "buildsystem": "simple", + "build-commands": [ + "pip3 install --verbose --exists-action=i --no-index --find-links=\"file://${PWD}\" --prefix=${FLATPAK_DEST} \"python-steamgriddb\" --no-build-isolation" + ], + "sources": [ + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/71/4c/3db2b8021bd6f2f0ceb0e088d6b2d49147671f25832fb17970e9b583d742/certifi-2022.12.7-py3-none-any.whl", + "sha256": "4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/ff/d7/8d757f8bd45be079d76309248845a04f09619a7b17d6dfc8c9ff6433cac2/charset-normalizer-3.1.0.tar.gz", + "sha256": "34e0a2f9c370eb95597aae63bf85eb5e96826d81e3dcf88b8886012906f509b5" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/fc/34/3030de6f1370931b9dbb4dad48f6ab1015ab1d32447850b9fc94e60097be/idna-3.4-py3-none-any.whl", + "sha256": "90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/d6/90/a84d927799ca177d4f7a111f99fee0a3f19da63e42c3b325c9c1bfe3bba0/python-steamgriddb-1.0.5.tar.gz", + "sha256": "036db7bb09865da73b40b68cf04fb9675cd18b4908275092d91f37bf16245069" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/d2/f4/274d1dbe96b41cf4e0efb70cbced278ffd61b5c7bb70338b62af94ccb25b/requests-2.28.2-py3-none-any.whl", + "sha256": "64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa" + }, + { + "type": "file", + "url": "https://files.pythonhosted.org/packages/7b/f5/890a0baca17a61c1f92f72b81d3c31523c99bec609e60c292ea55b387ae8/urllib3-1.26.15-py2.py3-none-any.whl", + "sha256": "aa751d169e23c7479ce47a0cb0da579e3ede798f994f5816a74e4f4500dcea42" + } + ] + }, { "name" : "cartridges", "builddir" : true, diff --git a/src/meson.build b/src/meson.build index 708174b..bd8b536 100644 --- a/src/meson.build +++ b/src/meson.build @@ -23,6 +23,7 @@ cartridges_sources = [ 'preferences.py', 'game.py', 'utils/importer.py', + 'utils/steamgriddb.py', 'utils/steam_parser.py', 'utils/lutris_parser.py', 'utils/heroic_parser.py', diff --git a/src/preferences.py b/src/preferences.py index 1c275aa..fc01057 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -83,6 +83,7 @@ class PreferencesWindow(Adw.PreferencesWindow): general_page = Gtk.Template.Child() import_page = Gtk.Template.Child() + sgdb_page = Gtk.Template.Child() sources_group = Gtk.Template.Child() @@ -113,6 +114,10 @@ class PreferencesWindow(Adw.PreferencesWindow): itch_expander_row = Gtk.Template.Child() itch_file_chooser_button = Gtk.Template.Child() + sgdb_key_entry_row = Gtk.Template.Child() + sgdb_download_switch = Gtk.Template.Child() + sgdb_prefer_switch = Gtk.Template.Child() + def __init__(self, parent_widget, **kwargs): super().__init__(**kwargs) self.schema = parent_widget.schema @@ -296,6 +301,27 @@ class PreferencesWindow(Adw.PreferencesWindow): True, ) + # SteamGridDB + self.schema.bind( + "sgdb-import", + self.sgdb_download_switch, + "active", + Gio.SettingsBindFlags.DEFAULT, + ) + + self.schema.bind( + "sgdb-prefer", + self.sgdb_prefer_switch, + "active", + Gio.SettingsBindFlags.DEFAULT, + ) + + def sgdb_key_changed(_widget): + self.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text()) + + self.sgdb_key_entry_row.set_text(self.schema.get_string("sgdb-key")) + self.sgdb_key_entry_row.connect("changed", sgdb_key_changed) + def choose_folder(self, _widget, function): self.file_chooser.select_folder(self.parent_widget, None, function, None) diff --git a/src/utils/bottles_parser.py b/src/utils/bottles_parser.py index 9d0ef51..ed45ab7 100644 --- a/src/utils/bottles_parser.py +++ b/src/utils/bottles_parser.py @@ -77,15 +77,15 @@ def bottles_parser(parent_widget): values["added"] = current_time values["last_played"] = 0 - if game["thumbnail"]: - importer.save_cover( - values["game_id"], - ( - bottles_dir - / "bottles" - / game["bottle"]["path"] - / "grids" - / game["thumbnail"].split(":")[1] - ), + importer.save_game( + values, + ( + bottles_dir + / "bottles" + / game["bottle"]["path"] + / "grids" + / game["thumbnail"].split(":")[1] ) - importer.save_game(values) + if game["thumbnail"] + else None, + ) diff --git a/src/utils/create_details_window.py b/src/utils/create_details_window.py index 3fd8b8e..a5ae905 100644 --- a/src/utils/create_details_window.py +++ b/src/utils/create_details_window.py @@ -37,7 +37,7 @@ def create_details_window(parent_widget, game_id=None): games = parent_widget.games pixbuf = None - if game_id is None: + if not game_id: window.set_title(_("Add New Game")) cover = Gtk.Picture.new_for_pixbuf(parent_widget.placeholder_pixbuf) name = Gtk.Entry() @@ -215,13 +215,13 @@ def create_details_window(parent_widget, game_id=None): create_dialog( window, _("Couldn't Add Game") - if game_id is None + if not game_id else _("Couldn't Apply Preferences"), f'{_("Executable")}: {exception}.', ) return - if game_id is None: + if not game_id: if final_name == "": create_dialog( window, _("Couldn't Add Game"), _("Game title cannot be empty.") @@ -267,7 +267,7 @@ def create_details_window(parent_widget, game_id=None): ) return - if pixbuf is not None: + if pixbuf: save_cover(parent_widget, game_id, None, pixbuf) values["name"] = final_name diff --git a/src/utils/heroic_parser.py b/src/utils/heroic_parser.py index 0221ce8..663eb02 100644 --- a/src/utils/heroic_parser.py +++ b/src/utils/heroic_parser.py @@ -100,10 +100,9 @@ def heroic_parser(parent_widget): (f'{game["art_square"]}?h=400&resize=1&w=300').encode() ).hexdigest() ) - if image_path.exists(): - importer.save_cover(values["game_id"], image_path) - importer.save_game(values) + importer.save_game(values, image_path if image_path.exists() else None) + except KeyError: pass @@ -142,9 +141,6 @@ def heroic_parser(parent_widget): / "images-cache" / hashlib.sha256(game["art_square"].encode()).hexdigest() ) - if image_path.exists(): - importer.save_cover(values["game_id"], image_path) - break values["executable"] = ( ["start", f"heroic://launch/{app_name}"] @@ -156,7 +152,7 @@ def heroic_parser(parent_widget): values["added"] = current_time values["last_played"] = 0 - importer.save_game(values) + importer.save_game(values, image_path if image_path.exists() else None) # Import sideloaded games if not schema.get_boolean("heroic-import-sideload"): @@ -196,7 +192,5 @@ def heroic_parser(parent_widget): / "images-cache" / hashlib.sha256(item["art_square"].encode()).hexdigest() ) - if image_path.exists(): - importer.save_cover(values["game_id"], image_path) - importer.save_game(values) + importer.save_game(values, image_path if image_path.exists() else None) diff --git a/src/utils/importer.py b/src/utils/importer.py index 951b06d..5dde5e7 100644 --- a/src/utils/importer.py +++ b/src/utils/importer.py @@ -24,6 +24,7 @@ from gi.repository import Adw, Gtk from .create_dialog import create_dialog from .save_cover import save_cover from .save_game import save_game +from .steamgriddb import SGDBSave class Importer: @@ -33,15 +34,17 @@ class Importer: self.queue = 0 self.games_no = 0 self.blocker = False + self.games = set() + self.sgdb_exception = None self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12) - import_statuspage = Adw.StatusPage( + self.import_statuspage = Adw.StatusPage( title=_("Importing Games…"), child=self.progressbar, ) self.import_dialog = Adw.Window( - content=import_statuspage, + content=self.import_statuspage, modal=True, default_width=350, default_height=-1, @@ -51,21 +54,35 @@ class Importer: self.import_dialog.present() - def save_cover(self, game_id, cover_path=None, pixbuf=None): - save_cover(self.parent_widget, game_id, cover_path, pixbuf) - - def save_game(self, values=None): + def save_game(self, values=None, cover_path=None, pixbuf=None): if values: - self.games_no += 1 save_game(self.parent_widget, values) - self.parent_widget.update_games([values["game_id"]]) + + if cover_path or pixbuf: + save_cover(self.parent_widget, values["game_id"], cover_path, pixbuf) + + self.games.add((values["game_id"], values["name"])) + + self.games_no += 1 if "blacklisted" in values: self.games_no -= 1 self.queue -= 1 - self.progressbar.set_fraction(1 - (self.queue / self.total_queue)) + self.update_progressbar() if self.queue == 0 and not self.blocker: + if self.games: + self.total_queue = len(self.games) + self.queue = len(self.games) + self.import_statuspage.set_title(_("Importing Covers…")) + self.update_progressbar() + SGDBSave(self, self.games) + else: + self.done() + + def done(self): + self.update_progressbar() + if self.queue == 0: self.import_dialog.close() if self.games_no == 0: @@ -75,14 +92,14 @@ class Importer: _("No new games were found on your system."), "open_preferences", _("Preferences"), - ).connect("response", self.response) + ).connect("response", self.response, "import") elif self.games_no == 1: create_dialog( self.parent_widget, _("Game Imported"), _("Successfully imported 1 game."), - ).connect("response", self.response) + ).connect("response", self.response, "import") elif self.games_no > 1: games_no = self.games_no create_dialog( @@ -90,13 +107,22 @@ class Importer: _("Games Imported"), # The variable is the number of games _("Successfully imported {} games.").format(games_no), - ).connect("response", self.response) + ).connect("response", self.response, "import") - def response(self, _widget, response, expander_row=None): + def response(self, _widget, response, page_name=None, expander_row=None): if response == "open_preferences": self.parent_widget.get_application().on_preferences_action( - None, page_name="import", expander_row=expander_row + None, page_name=page_name, expander_row=expander_row ) + elif self.sgdb_exception: + create_dialog( + self.parent_widget, + _("Couldn't Connect to SteamGridDB"), + self.sgdb_exception, + "open_preferences", + _("Preferences"), + ).connect("response", self.response, "sgdb") + self.sgdb_exception = None elif ( self.parent_widget.schema.get_boolean("steam") and self.parent_widget.schema.get_boolean("steam-extra-dirs-hint") @@ -120,4 +146,7 @@ class Importer: ), "open_preferences", _("Preferences"), - ).connect("response", self.response, "steam_expander_row") + ).connect("response", self.response, "import", "steam_expander_row") + + def update_progressbar(self): + self.progressbar.set_fraction(1 - (self.queue / self.total_queue)) diff --git a/src/utils/itch_parser.py b/src/utils/itch_parser.py index c165b07..ce7d46a 100644 --- a/src/utils/itch_parser.py +++ b/src/utils/itch_parser.py @@ -35,7 +35,7 @@ def get_game(task, current_time, parent_widget, row, importer): values["game_id"] in parent_widget.games and not parent_widget.games[values["game_id"]].removed ): - task.return_value(None) + task.return_value((None, None)) return values["added"] = current_time @@ -51,7 +51,7 @@ def get_game(task, current_time, parent_widget, row, importer): with urllib.request.urlopen(row[3] or row[2], timeout=5) as open_file: Path(tmp_file.get_path()).write_bytes(open_file.read()) except urllib.error.URLError: - task.return_value(values) + task.return_value((values, None)) return cover_pixbuf = GdkPixbuf.Pixbuf.new_from_stream_at_scale( @@ -77,8 +77,10 @@ def get_game(task, current_time, parent_widget, row, importer): GdkPixbuf.InterpType.BILINEAR, 255, ) - importer.save_cover(values["game_id"], pixbuf=cover_pixbuf) - task.return_value(values) + else: + cover_pixbuf = None + + task.return_value((values, cover_pixbuf)) def get_games_async(parent_widget, rows, importer): @@ -100,7 +102,7 @@ def get_games_async(parent_widget, rows, importer): def update_games(_task, result): final_values = result.propagate_value()[1] # No need for an if statement as final_value would be None for games we don't want to save - importer.save_game(final_values) + importer.save_game(final_values[0], pixbuf=final_values[1]) for row in rows: task = Gio.Task.new(None, None, update_games) diff --git a/src/utils/lutris_parser.py b/src/utils/lutris_parser.py index 8961fe1..56f47d4 100644 --- a/src/utils/lutris_parser.py +++ b/src/utils/lutris_parser.py @@ -108,9 +108,5 @@ def lutris_parser(parent_widget): values["name"] = row[1] values["source"] = f"lutris_{row[3]}" - if (cache_dir / "coverart" / f"{row[2]}.jpg").is_file(): - importer.save_cover( - values["game_id"], (cache_dir / "coverart" / f"{row[2]}.jpg") - ) - - importer.save_game(values) + image_path = cache_dir / "coverart" / f"{row[2]}.jpg" + importer.save_game(values, image_path if image_path.exists() else None) diff --git a/src/utils/save_cover.py b/src/utils/save_cover.py index 22b3638..0681a3d 100644 --- a/src/utils/save_cover.py +++ b/src/utils/save_cover.py @@ -26,7 +26,7 @@ def save_cover(parent_widget, game_id, cover_path=None, pixbuf=None): covers_dir.mkdir(parents=True, exist_ok=True) - if pixbuf is None: + if not pixbuf: pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_scale( str(cover_path), 400, 600, False ) diff --git a/src/utils/steam_parser.py b/src/utils/steam_parser.py index f3bce0a..ec12d55 100644 --- a/src/utils/steam_parser.py +++ b/src/utils/steam_parser.py @@ -57,7 +57,7 @@ def get_game( values["game_id"] in parent_widget.games and not parent_widget.games[values["game_id"]].removed ): - task.return_value(None) + task.return_value((None, None)) return values["executable"] = ( @@ -70,21 +70,12 @@ def get_game( values["added"] = current_time values["last_played"] = 0 - if ( + image_path = ( steam_dir / "appcache" / "librarycache" / f'{values["appid"]}_library_600x900.jpg' - ).is_file(): - importer.save_cover( - values["game_id"], - ( - steam_dir - / "appcache" - / "librarycache" - / f'{values["appid"]}_library_600x900.jpg' - ), - ) + ) try: with urllib.request.urlopen( @@ -93,11 +84,11 @@ def get_game( ) as open_file: content = open_file.read().decode("utf-8") except urllib.error.URLError: - task.return_value(values) + task.return_value((values, image_path if image_path.exists() else None)) return values = update_values_from_data(content, values) - task.return_value(values) + task.return_value((values, image_path if image_path.exists() else None)) def get_games_async(parent_widget, appmanifests, steam_dir, importer): @@ -122,7 +113,7 @@ def get_games_async(parent_widget, appmanifests, steam_dir, importer): def update_games(_task, result): final_values = result.propagate_value()[1] # No need for an if statement as final_value would be None for games we don't want to save - importer.save_game(final_values) + importer.save_game(final_values[0], final_values[1]) for appmanifest in appmanifests: task = Gio.Task.new(None, None, update_games) diff --git a/src/utils/steamgriddb.py b/src/utils/steamgriddb.py new file mode 100644 index 0000000..6417338 --- /dev/null +++ b/src/utils/steamgriddb.py @@ -0,0 +1,69 @@ +from pathlib import Path + +import requests +from gi.repository import Gio +from steamgrid import SteamGridDB, http + +from .save_cover import save_cover + + +class SGDBSave: + def __init__(self, importer, games): + self.importer = importer + self.sgdb = SteamGridDB(importer.parent_widget.schema.get_string("sgdb-key")) + + # Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args + def create_func(game): + def wrapper(task, *_unused): + self.update_cover( + task, + game, + ) + + return wrapper + + for game in games: + Gio.Task.new(None, None, self.task_done).run_in_thread(create_func(game)) + + def update_cover(self, task, game): + if self.importer.parent_widget.schema.get_boolean("sgdb-prefer") or ( + self.importer.parent_widget.schema.get_boolean("sgdb-import") + and self.importer.parent_widget.games[game[0]].pixbuf + == self.importer.parent_widget.placeholder_pixbuf + ): + try: + search_result = self.sgdb.search_game(game[1]) + except requests.exceptions.RequestException: + task.return_value(game[0]) + return + except http.HTTPException as exception: + self.importer.sgdb_exception = str(exception) + task.return_value(game[0]) + return + + try: + grid = self.sgdb.get_grids_by_gameid( + [search_result[0].id], is_nsfw=False + )[0] + except (TypeError, IndexError): + task.return_value(game[0]) + return + + tmp_file = Gio.File.new_tmp(None)[0] + + try: + response = requests.get(str(grid), timeout=5) + except requests.exceptions.RequestException: + task.return_value(game[0]) + return + + Path(tmp_file.get_path()).write_bytes(response.content) + save_cover(self.importer.parent_widget, game[0], tmp_file.get_path()) + + task.return_value(game[0]) + + def task_done(self, _task, result): + game_id = result.propagate_value()[1] + self.importer.parent_widget.update_games([game_id]) + self.importer.queue -= 1 + self.importer.done()