Merge pull request #103 from kra-mo/gali-importer-structure

Gali importer structure
This commit is contained in:
kramo
2023-06-26 11:53:16 +02:00
committed by GitHub
59 changed files with 3813 additions and 1699 deletions

32
.pylintrc Normal file
View File

@@ -0,0 +1,32 @@
[MAIN]
ignore=importers
[MESSAGES CONTROL]
disable=raw-checker-failed,
bad-inline-option,
locally-disabled,
file-ignored,
suppressed-message,
useless-suppression,
deprecated-pragma,
use-symbolic-message-instead,
too-few-public-methods,
missing-function-docstring,
missing-class-docstring,
missing-module-docstring,
relative-beyond-top-level,
import-error,
no-name-in-module
[TYPECHECK]
ignored-classes=Child
[VARIABLES]
additional-builtins=_

View File

@@ -5,9 +5,10 @@
<file preprocess="xml-stripblanks">gtk/help-overlay.ui</file>
<file preprocess="xml-stripblanks">gtk/game.ui</file>
<file preprocess="xml-stripblanks">gtk/preferences.ui</file>
<file preprocess="xml-stripblanks">gtk/details_window.ui</file>
<file preprocess="xml-stripblanks">gtk/details-window.ui</file>
<file alias="style.css">gtk/style.css</file>
<file alias="style-dark.css">gtk/style-dark.css</file>
<file>library_placeholder.svg</file>
<file>library_placeholder_small.svg</file>
</gresource>
</gresources>

View File

@@ -129,17 +129,19 @@ template $DetailsWindow : Adw.Window {
icon-name: "help-about-symbolic";
tooltip-text: _("More Info");
popover: Popover {
visible: bind-property exec_info_button.active bidirectional;
popover: Popover exec_info_popover {
Label exec_info_label {
use-markup: true;
wrap: true;
max-width-chars: 30;
max-width-chars: 50;
halign: center;
valign: center;
margin-top: 6;
margin-bottom: 12;
margin-bottom: 6;
margin-start: 6;
margin-end: 6;
selectable: true;
}
};

View File

@@ -61,6 +61,22 @@ template $PreferencesWindow : Adw.PreferencesWindow {
]
}
}
Adw.ActionRow reset_action_row {
title: "Reset App";
subtitle: "Completely resets and quits Cartridges";
visible: false;
Button reset_button {
label: "Reset";
valign: center;
styles [
"destructive-action",
]
}
}
}
}
@@ -76,10 +92,10 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Steam");
show-enable-switch: true;
Adw.ActionRow steam_action_row {
title: _("Steam Install Location");
Adw.ActionRow steam_data_action_row {
title: _("Install Location");
Button steam_file_chooser_button {
Button steam_data_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
@@ -90,17 +106,17 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Lutris");
show-enable-switch: true;
Adw.ActionRow lutris_action_row {
title: _("Lutris Install Location");
Adw.ActionRow lutris_data_action_row {
title: _("Install Location");
Button lutris_file_chooser_button {
Button lutris_data_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
Adw.ActionRow lutris_cache_action_row {
title: _("Lutris Cache Location");
title: _("Cache Location");
Button lutris_cache_file_chooser_button {
icon-name: "folder-symbolic";
@@ -122,10 +138,10 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Heroic");
show-enable-switch: true;
Adw.ActionRow heroic_action_row {
title: _("Heroic Install Location");
Adw.ActionRow heroic_config_action_row {
title: _("Install Location");
Button heroic_file_chooser_button {
Button heroic_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
@@ -163,10 +179,10 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Bottles");
show-enable-switch: true;
Adw.ActionRow bottles_action_row {
title: _("Bottles Install Location");
Adw.ActionRow bottles_data_action_row {
title: _("Install Location");
Button bottles_file_chooser_button {
Button bottles_data_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
@@ -177,10 +193,24 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("itch");
show-enable-switch: true;
Adw.ActionRow itch_action_row {
title: _("itch Install Location");
Adw.ActionRow itch_config_action_row {
title: _("Install Location");
Button itch_file_chooser_button {
Button itch_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
}
Adw.ExpanderRow legendary_expander_row {
title: _("Legendary");
show-enable-switch: true;
Adw.ActionRow legendary_config_action_row {
title: _("Install Location");
Button legendary_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}

View File

@@ -1,9 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<schemalist gettext-domain="cartridges">
<schema id="@APP_ID@" path="@PREFIX@/">
<key name="exit-after-launch" type="b">
<default>false</default>
</key>
<schema id="@APP_ID@" path="@PREFIX@/">
<key name="exit-after-launch" type="b">
<default>false</default>
</key>
<key name="cover-launches-game" type="b">
<default>false</default>
</key>
@@ -32,21 +32,21 @@
<default>true</default>
</key>
<key name="heroic-location" type="s">
<default>"~/.var/app/com.heroicgameslauncher.hgl/config/heroic/"</default>
<default>"~/.config/heroic/"</default>
</key>
<key name="heroic-import-epic" type="b">
<default>true</default>
</key>
<key name="heroic-import-gog" type="b">
<default>true</default>
</key>
<key name="heroic-import-sideload" type="b">
<default>true</default>
</key>
<key name="heroic-import-epic" type="b">
<default>true</default>
</key>
<key name="heroic-import-gog" type="b">
<default>true</default>
</key>
<key name="heroic-import-sideload" type="b">
<default>true</default>
</key>
<key name="bottles" type="b">
<default>true</default>
</key>
<key name="bottles-location" type="s">
<key name="bottles-location" type="s">
<default>"~/.var/app/com.usebottles.bottles/data/bottles/"</default>
</key>
<key name="itch" type="b">
@@ -55,6 +55,12 @@
<key name="itch-location" type="s">
<default>"~/.var/app/io.itch.itch/config/itch/"</default>
</key>
<key name="legendary" type="b">
<default>true</default>
</key>
<key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default>
</key>
<key name="sgdb-key" type="s">
<default>""</default>
</key>
@@ -67,7 +73,7 @@
<key name="sgdb-animated" type="b">
<default>false</default>
</key>
</schema>
</schema>
<schema id="@APP_ID@.State" path="@PREFIX@/State/">
<key name="width" type="i">
<default>1110</default>
@@ -80,13 +86,16 @@
</key>
<key name="sort-mode" type="s">
<choices>
<choice value="a-z"/>
<choice value="z-a"/>
<choice value="newest"/>
<choice value="oldest"/>
<choice value="last_played"/>
<choice value="a-z" />
<choice value="z-a" />
<choice value="newest" />
<choice value="oldest" />
<choice value="last_played" />
</choices>
<default>"a-z"</default>
</key>
<key name="steam-limiter-tokens-history" type="s">
<default>"[]"</default>
</key>
</schema>
</schemalist>
</schemalist>

View File

@@ -0,0 +1 @@
<svg width="2" height="2" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill="url(#a)" d="M0 0h2v2H0z"/><defs><linearGradient id="a" x1="1" y1="0" x2="1" y2="2" gradientUnits="userSpaceOnUse"><stop stop-color="#9A9996"/><stop offset="1" stop-color="#5E5C64"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 296 B

View File

@@ -4,7 +4,7 @@ blueprints = custom_target('blueprints',
'gtk/window.blp',
'gtk/game.blp',
'gtk/preferences.blp',
'gtk/details_window.blp'
'gtk/details-window.blp'
),
output: '.',
command: [find_program('blueprint-compiler'), 'batch-compile', '@OUTPUT@', '@CURRENT_SOURCE_DIR@', '@INPUT@'],

116
docs/game_id.json.md Normal file
View File

@@ -0,0 +1,116 @@
# [game_id].json specification
#### Version 2.0
Games are saved to disk in the form of [game_id].json files. These files contain all information about a game excluding its cover, which is handled separately.
## Location
The standard location for these files is `/cartridges/games/` under the data directory of the user (`XDG_DATA_HOME` on Linux).
## Contents
The following attributes are saved:
- [added](#added)
- [executable](#executable)
- [game_id](#game_id)
- [source](#source)
- [hidden](#hidden)
- [last_played](#last_played)
- [name](#name)
- [developer](#developer)
- [removed](#removed)
- [blacklisted](#blacklisted)
- [version](#version)
### added
The date at which the game was added.
Cartridges will set the value for itself. Don't touch it.
Stored as a Unix time stamp.
### executable
The executable to run when launching a game.
If the source has a URL handler, using that is preferred. In that case, the value should be `"xdg-open url://example/url"` for Linux and `"start url://example/url"` for Windows.
Stored as either a string (preferred) or an argument vector to be passed to the shell through [subprocess.Popen](https://docs.python.org/3/library/subprocess.html#popen-constructor).
### game_id
The unique ID of the game, prefixed with [`[source]_`](#source) to avoid clashes.
If the game's source uses a consistent internal ID system, use the ID from there. If not, use a hash function that always returns the same hash for the same game, even if some of its attributes change inside of the source.
Stored as a string.
### source
A unique ID for the source of the game in lowercase, without spaces.
If a source provides multiple internal sources, these should be separately labeled, but share a common prefix. eg. `heoic_gog`, `heroic_epic`.
Stored as a string.
### hidden
Whether or not a game is hidden.
If the source provides a way of hiding games, take the value from there. Otherwise it should be set to false by default.
Stored as a boolean.
### last_played
The date at which the game was last launched from Cartridges.
Cartridges will set the value for itself. Don't touch it.
Stored as a Unix time stamp. 0 if the game hasn't been played yet.
### name
The title of the game.
Stored as a string.
### developer
The developer or publisher of the game.
If there are multiple developers or publishers, they should be joined with a comma and a space (`, `) into one string.
This is an optional attribute. If it can't be retrieved from the source, don't touch it.
Stored as a string.
### removed
Whether or not a game has been removed.
Cartridges will set the value for itself. Don't touch it.
Stored as a boolean.
### blacklisted
Whether or not a game is blacklisted. Blacklisting a game means it is going to still be imported, but not displayed to the user.
You should only blacklist a game based on information you pull from the web. This is to ensure that games which you would skip based on information online are still skipped even if the user loses their internet connection. If an entry is broken locally, just skip it.
The only reason to blacklist a game is if you find out that the locally cached entry is not actually a game (eg. Proton) or is otherwise invalid.
Unless the above criteria is met, don't touch the attribute.
Stored as a boolean.
### version
The version number of the [game_id].json specification.
Cartridges will set the value for itself. Don't touch it.
Stored as a number.

View File

@@ -24,6 +24,7 @@ conf.set('PYTHON', python.find_installation('python3').full_path())
conf.set('APP_ID', app_id)
conf.set('PREFIX', prefix)
conf.set('VERSION', meson.project_version())
conf.set('PROFILE', profile)
conf.set('localedir', join_paths(get_option('prefix'), get_option('localedir')))
conf.set('pkgdatadir', pkgdatadir)

View File

@@ -2,7 +2,7 @@ data/hu.kramo.Cartridges.desktop.in
data/hu.kramo.Cartridges.gschema.xml.in
data/hu.kramo.Cartridges.metainfo.xml.in
data/gtk/details_window.blp
data/gtk/details-window.blp
data/gtk/game.blp
data/gtk/help-overlay.blp
data/gtk/preferences.blp
@@ -15,5 +15,5 @@ src/game.py
src/preferences.py
src/utils/create_dialog.py
src/utils/importer.py
src/utils/steamgriddb.py
src/importer/sources/source.py
src/store/managers/sgdb_manager.py

View File

@@ -8,18 +8,18 @@ msgid ""
msgstr ""
"Project-Id-Version: Cartridges\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-05-25 17:48+0200\n"
"POT-Creation-Date: 2023-06-26 11:37+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Type: text/plain; charset=CHARSET\n"
"Content-Transfer-Encoding: 8bit\n"
#: data/hu.kramo.Cartridges.desktop.in:3
#: data/hu.kramo.Cartridges.metainfo.xml.in:6 data/gtk/window.blp:48
#: src/main.py:109
#: data/hu.kramo.Cartridges.metainfo.xml.in:6 data/gtk/window.blp:47
#: src/main.py:146
msgid "Cartridges"
msgstr ""
@@ -48,75 +48,74 @@ msgstr ""
msgid "Library"
msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:34 src/details_window.py:66
#: data/hu.kramo.Cartridges.metainfo.xml.in:34 src/details_window.py:67
msgid "Edit Game Details"
msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:38 data/gtk/window.blp:72
#: data/hu.kramo.Cartridges.metainfo.xml.in:38 data/gtk/window.blp:71
msgid "Game Details"
msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:417
#: src/utils/importer.py:92 src/utils/importer.py:124
#: src/utils/steamgriddb.py:115
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:416
#: src/details_window.py:239
msgid "Preferences"
msgstr ""
#: data/gtk/details_window.blp:25
#: data/gtk/details-window.blp:25
msgid "Cancel"
msgstr ""
#: data/gtk/details_window.blp:57
#: data/gtk/details-window.blp:57
msgid "New Cover"
msgstr ""
#: data/gtk/details_window.blp:75
#: data/gtk/details-window.blp:75
msgid "Delete Cover"
msgstr ""
#: data/gtk/details_window.blp:101 data/gtk/details_window.blp:106
#: data/gtk/details-window.blp:101 data/gtk/details-window.blp:106
#: data/gtk/game.blp:80
msgid "Title"
msgstr ""
#: data/gtk/details_window.blp:102
#: data/gtk/details-window.blp:102
msgid "The title of the game"
msgstr ""
#: data/gtk/details_window.blp:112 data/gtk/details_window.blp:117
#: data/gtk/details-window.blp:112 data/gtk/details-window.blp:117
msgid "Developer"
msgstr ""
#: data/gtk/details_window.blp:113
#: data/gtk/details-window.blp:113
msgid "The developer or publisher (optional)"
msgstr ""
#: data/gtk/details_window.blp:123 data/gtk/details_window.blp:153
#: data/gtk/details-window.blp:123 data/gtk/details-window.blp:155
msgid "Executable"
msgstr ""
#: data/gtk/details_window.blp:124
#: data/gtk/details-window.blp:124
msgid "File to open or command to run when launching the game"
msgstr ""
#: data/gtk/details_window.blp:130
#: data/gtk/details-window.blp:130
msgid "More Info"
msgstr ""
#: data/gtk/game.blp:102 data/gtk/game.blp:121 data/gtk/window.blp:196
#: data/gtk/game.blp:102 data/gtk/game.blp:121 data/gtk/window.blp:195
msgid "Edit"
msgstr ""
#: data/gtk/game.blp:107 src/window.py:205
#: data/gtk/game.blp:107 src/window.py:169
msgid "Hide"
msgstr ""
#: data/gtk/game.blp:112 data/gtk/game.blp:131 data/gtk/preferences.blp:56
#: data/gtk/window.blp:210
#: data/gtk/window.blp:209
msgid "Remove"
msgstr ""
#: data/gtk/game.blp:126 src/window.py:207
#: data/gtk/game.blp:126 src/window.py:171
msgid "Unhide"
msgstr ""
@@ -128,8 +127,8 @@ msgstr ""
msgid "Quit"
msgstr ""
#: data/gtk/help-overlay.blp:19 data/gtk/window.blp:218 data/gtk/window.blp:258
#: data/gtk/window.blp:324
#: data/gtk/help-overlay.blp:19 data/gtk/window.blp:217 data/gtk/window.blp:257
#: data/gtk/window.blp:323
msgid "Search"
msgstr ""
@@ -141,7 +140,7 @@ msgstr ""
msgid "Shortcuts"
msgstr ""
#: data/gtk/help-overlay.blp:34 src/game.py:169 src/preferences.py:98
#: data/gtk/help-overlay.blp:34 src/game.py:105 src/preferences.py:103
msgid "Undo"
msgstr ""
@@ -169,7 +168,7 @@ msgstr ""
msgid "Remove game"
msgstr ""
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:206
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:236
msgid "Behavior"
msgstr ""
@@ -185,7 +184,7 @@ msgstr ""
msgid "Swaps the behavior of the cover image and the play button"
msgstr ""
#: data/gtk/preferences.blp:36 src/details_window.py:84
#: data/gtk/preferences.blp:36 src/details_window.py:81
msgid "Images"
msgstr ""
@@ -205,99 +204,89 @@ msgstr ""
msgid "Remove All Games"
msgstr ""
#: data/gtk/preferences.blp:69 data/gtk/window.blp:28 data/gtk/window.blp:443
#: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:442
msgid "Import"
msgstr ""
#: data/gtk/preferences.blp:73
#: data/gtk/preferences.blp:89
msgid "Sources"
msgstr ""
#: data/gtk/preferences.blp:76
#: data/gtk/preferences.blp:92
msgid "Steam"
msgstr ""
#: data/gtk/preferences.blp:80
msgid "Steam Install Location"
#: data/gtk/preferences.blp:96 data/gtk/preferences.blp:110
#: data/gtk/preferences.blp:142 data/gtk/preferences.blp:183
#: data/gtk/preferences.blp:197 data/gtk/preferences.blp:211
msgid "Install Location"
msgstr ""
#: data/gtk/preferences.blp:90
#: data/gtk/preferences.blp:106
msgid "Lutris"
msgstr ""
#: data/gtk/preferences.blp:94
msgid "Lutris Install Location"
#: data/gtk/preferences.blp:119
msgid "Cache Location"
msgstr ""
#: data/gtk/preferences.blp:103
msgid "Lutris Cache Location"
msgstr ""
#: data/gtk/preferences.blp:112
#: data/gtk/preferences.blp:128
msgid "Import Steam Games"
msgstr ""
#: data/gtk/preferences.blp:122
#: data/gtk/preferences.blp:138
msgid "Heroic"
msgstr ""
#: data/gtk/preferences.blp:126
msgid "Heroic Install Location"
msgstr ""
#: data/gtk/preferences.blp:135
#: data/gtk/preferences.blp:151
msgid "Import Epic Games"
msgstr ""
#: data/gtk/preferences.blp:144
#: data/gtk/preferences.blp:160
msgid "Import GOG Games"
msgstr ""
#: data/gtk/preferences.blp:153
#: data/gtk/preferences.blp:169
msgid "Import Sideloaded Games"
msgstr ""
#: data/gtk/preferences.blp:163
#: data/gtk/preferences.blp:179
msgid "Bottles"
msgstr ""
#: data/gtk/preferences.blp:167
msgid "Bottles Install Location"
msgstr ""
#: data/gtk/preferences.blp:177
#: data/gtk/preferences.blp:193
msgid "itch"
msgstr ""
#: data/gtk/preferences.blp:181
msgid "itch Install Location"
#: data/gtk/preferences.blp:207
msgid "Legendary"
msgstr ""
#: data/gtk/preferences.blp:194
#: data/gtk/preferences.blp:224
msgid "SteamGridDB"
msgstr ""
#: data/gtk/preferences.blp:198
#: data/gtk/preferences.blp:228
msgid "Authentication"
msgstr ""
#: data/gtk/preferences.blp:201
#: data/gtk/preferences.blp:231
msgid "API Key"
msgstr ""
#: data/gtk/preferences.blp:209
#: data/gtk/preferences.blp:239
msgid "Use SteamGridDB"
msgstr ""
#: data/gtk/preferences.blp:210
#: data/gtk/preferences.blp:240
msgid "Download images when adding or importing games"
msgstr ""
#: data/gtk/preferences.blp:219
#: data/gtk/preferences.blp:249
msgid "Prefer Over Official Images"
msgstr ""
#: data/gtk/preferences.blp:228
#: data/gtk/preferences.blp:258
msgid "Prefer Animated Images"
msgstr ""
@@ -309,142 +298,134 @@ msgstr ""
msgid "Try a different search."
msgstr ""
#: data/gtk/window.blp:22
#: data/gtk/window.blp:21
msgid "No Games"
msgstr ""
#: data/gtk/window.blp:23
#: data/gtk/window.blp:22
msgid "Use the + button to add games."
msgstr ""
#: data/gtk/window.blp:41
#: data/gtk/window.blp:40
msgid "No Hidden Games"
msgstr ""
#: data/gtk/window.blp:42
#: data/gtk/window.blp:41
msgid "Games you hide will appear here."
msgstr ""
#: data/gtk/window.blp:65 data/gtk/window.blp:305
#: data/gtk/window.blp:64 data/gtk/window.blp:304
msgid "Back"
msgstr ""
#: data/gtk/window.blp:122
#: data/gtk/window.blp:121
msgid "Game Title"
msgstr ""
#: data/gtk/window.blp:177
#: data/gtk/window.blp:176
msgid "Play"
msgstr ""
#: data/gtk/window.blp:244 data/gtk/window.blp:436
#: data/gtk/window.blp:243 data/gtk/window.blp:435
msgid "Add Game"
msgstr ""
#: data/gtk/window.blp:251 data/gtk/window.blp:317
#: data/gtk/window.blp:250 data/gtk/window.blp:316
msgid "Main Menu"
msgstr ""
#: data/gtk/window.blp:312
#: data/gtk/window.blp:311
msgid "Hidden Games"
msgstr ""
#: data/gtk/window.blp:375
#: data/gtk/window.blp:374
msgid "Sort"
msgstr ""
#: data/gtk/window.blp:378
#: data/gtk/window.blp:377
msgid "A-Z"
msgstr ""
#: data/gtk/window.blp:384
#: data/gtk/window.blp:383
msgid "Z-A"
msgstr ""
#: data/gtk/window.blp:390
#: data/gtk/window.blp:389
msgid "Newest"
msgstr ""
#: data/gtk/window.blp:396
#: data/gtk/window.blp:395
msgid "Oldest"
msgstr ""
#: data/gtk/window.blp:402
#: data/gtk/window.blp:401
msgid "Last Played"
msgstr ""
#: data/gtk/window.blp:409
#: data/gtk/window.blp:408
msgid "Show Hidden"
msgstr ""
#: data/gtk/window.blp:422
#: data/gtk/window.blp:421
msgid "Keyboard Shortcuts"
msgstr ""
#: data/gtk/window.blp:427
#: data/gtk/window.blp:426
msgid "About Cartridges"
msgstr ""
#. Translators: Replace this with your name for it to show up in the about window
#: src/main.py:127
#: src/main.py:164
msgid "translator_credits"
msgstr ""
#: src/window.py:187
msgid "Today"
msgstr ""
#: src/window.py:189
msgid "Yesterday"
msgstr ""
#. The variable is the date when the game was added
#: src/window.py:228
#: src/window.py:192
msgid "Added: {}"
msgstr ""
#: src/window.py:231
#: src/window.py:195
msgid "Never"
msgstr ""
#. The variable is the date when the game was last played
#: src/window.py:235
#: src/window.py:199
msgid "Last played: {}"
msgstr ""
#: src/details_window.py:75
#: src/details_window.py:72
msgid "Apply"
msgstr ""
#: src/details_window.py:81
#: src/details_window.py:78
msgid "Add New Game"
msgstr ""
#: src/details_window.py:82
#: src/details_window.py:79
msgid "Confirm"
msgstr ""
#. Translate this string as you would translate "file"
#: src/details_window.py:94
#: src/details_window.py:91
msgid "file.txt"
msgstr ""
#. As in software
#: src/details_window.py:96
#: src/details_window.py:93
msgid "program"
msgstr ""
#. Translate this string as you would translate "path to {}"
#: src/details_window.py:101 src/details_window.py:103
#: src/details_window.py:98 src/details_window.py:100
msgid "C:\\path\\to\\{}"
msgstr ""
#. Translate this string as you would translate "path to {}"
#: src/details_window.py:107 src/details_window.py:109
#: src/details_window.py:104 src/details_window.py:106
msgid "/path/to/{}"
msgstr ""
#: src/details_window.py:113
#: src/details_window.py:111
msgid ""
"To launch the executable \"{}\", use the command:\n"
"\n"
@@ -457,101 +438,82 @@ msgid ""
"If the path contains spaces, make sure to wrap it in double quotes!"
msgstr ""
#: src/details_window.py:143 src/details_window.py:149
#: src/details_window.py:146 src/details_window.py:152
msgid "Couldn't Add Game"
msgstr ""
#: src/details_window.py:143 src/details_window.py:176
#: src/details_window.py:146 src/details_window.py:181
msgid "Game title cannot be empty."
msgstr ""
#: src/details_window.py:149 src/details_window.py:184
#: src/details_window.py:152 src/details_window.py:189
msgid "Executable cannot be empty."
msgstr ""
#: src/details_window.py:175 src/details_window.py:183
#: src/details_window.py:180 src/details_window.py:188
msgid "Couldn't Apply Preferences"
msgstr ""
#. The variable is the title of the game
#: src/game.py:208
#: src/game.py:141
msgid "{} launched"
msgstr ""
#. The variable is the title of the game
#: src/game.py:220
#: src/game.py:154
msgid "{} hidden"
msgstr ""
#: src/game.py:220
#: src/game.py:154
msgid "{} unhidden"
msgstr ""
#. The variable is the title of the game
#: src/game.py:233
#: src/game.py:171
msgid "{} removed"
msgstr ""
#: src/preferences.py:97
#: src/preferences.py:102
msgid "All games removed"
msgstr ""
#: src/preferences.py:136
msgid "Cache Not Found"
msgstr ""
#: src/preferences.py:137
msgid "Select the Lutris cache directory."
msgstr ""
#: src/preferences.py:139 src/preferences.py:292
msgid "Set Location"
msgstr ""
#: src/preferences.py:166
#: src/preferences.py:149
msgid ""
"An API key is required to use SteamGridDB. You can generate one {}here{}."
msgstr ""
#: src/preferences.py:286
msgid "Installation Not Found"
msgstr ""
#. The variable is the name of the game launcher
#: src/preferences.py:288
msgid "Select the {} configuration directory."
msgstr ""
#. The variable is the name of the game launcher
#: src/preferences.py:290
msgid "Select the {} data directory."
#: src/preferences.py:289
msgid "Set Location"
msgstr ""
#: src/utils/create_dialog.py:25
msgid "Dismiss"
msgstr ""
#: src/utils/importer.py:41
msgid "Importing Games…"
#: src/importer/sources/source.py:106
msgid "Data"
msgstr ""
#: src/utils/importer.py:76
msgid "Importing Covers…"
#: src/importer/sources/source.py:107
msgid "Cache"
msgstr ""
#: src/utils/importer.py:91
msgid "No new games found"
#: src/importer/sources/source.py:108
msgid "Configuration"
msgstr ""
#: src/utils/importer.py:98
msgid "1 game imported"
#. The variables are the type of location (eg. cache) and the source's name
#: src/importer/sources/source.py:119
msgid "Invalid {} Location for {{}}"
msgstr ""
#. The variable is the number of games
#: src/utils/importer.py:104
msgid "{} games imported"
#: src/importer/sources/source.py:120
msgid "Change it or disable the source in preferences"
msgstr ""
#: src/utils/importer.py:121 src/utils/steamgriddb.py:112
msgid "Couldn't Connect to SteamGridDB"
#: src/store/managers/sgdb_manager.py:47
msgid "Couldn't Authenticate SteamGridDB"
msgstr ""
#: src/store/managers/sgdb_manager.py:48
msgid "Verify your API key in preferences"
msgstr ""

View File

View File

@@ -55,6 +55,6 @@ if __name__ == "__main__":
resource = Gio.Resource.load(os.path.join(pkgdatadir, "cartridges.gresource"))
resource._register()
from cartridges import main
from src import main
sys.exit(main.main(VERSION))

View File

@@ -18,21 +18,21 @@
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import shlex
from time import time
from gi.repository import Adw, Gio, GLib, Gtk
from PIL import Image
from . import shared
from .create_dialog import create_dialog
from .game import Game
from .game_cover import GameCover
from .save_cover import resize_cover, save_cover
from .steamgriddb import SGDBSave
from src import shared
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.game_cover import GameCover
from src.store.managers.sgdb_manager import SGDBManager
from src.utils.create_dialog import create_dialog
from src.utils.save_cover import resize_cover, save_cover
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/details_window.ui")
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/details-window.ui")
class DetailsWindow(Adw.Window):
__gtype_name__ = "DetailsWindow"
@@ -48,6 +48,7 @@ class DetailsWindow(Adw.Window):
executable = Gtk.Template.Child()
exec_info_label = Gtk.Template.Child()
exec_info_popover = Gtk.Template.Child()
apply_button = Gtk.Template.Child()
@@ -67,15 +68,11 @@ class DetailsWindow(Adw.Window):
self.name.set_text(self.game.name)
if self.game.developer:
self.developer.set_text(self.game.developer)
self.executable.set_text(
self.game.executable
if isinstance(self.game.executable, str)
else shlex.join(self.game.executable)
)
self.executable.set_text(self.game.executable)
self.apply_button.set_label(_("Apply"))
self.game_cover.new_cover(self.game.get_cover_path())
if self.game_cover.get_pixbuf():
if self.game_cover.get_texture():
self.cover_button_delete_revealer.set_reveal_child(True)
else:
self.set_title(_("Add New Game"))
@@ -109,12 +106,18 @@ class DetailsWindow(Adw.Window):
file_path = _("/path/to/{}").format(file_name)
command = "xdg-open"
# pylint: disable=line-too-long
exec_info_text = _(
'To launch the executable "{}", use the command:\n\n<tt>"{}"</tt>\n\nTo open the file "{}" with the default application, use:\n\n<tt>{} "{}"</tt>\n\nIf the path contains spaces, make sure to wrap it in double quotes!'
).format(exe_name, exe_path, file_name, command, file_path)
self.exec_info_label.set_label(exec_info_text)
def clear_info_selection(*_args):
self.exec_info_label.select_region(0, 0)
self.exec_info_popover.connect("show", clear_info_selection)
self.cover_button_delete.connect("clicked", self.delete_pixbuf)
self.cover_button_edit.connect("clicked", self.choose_cover)
self.apply_button.connect("clicked", self.apply_preferences)
@@ -151,21 +154,23 @@ class DetailsWindow(Adw.Window):
return
# Increment the number after the game id (eg. imported_1, imported_2)
numbers = [0]
for current_game in self.win.games:
if "imported_" in current_game:
numbers.append(int(current_game.replace("imported_", "")))
game_id: str
for game_id in shared.store.games:
prefix = "imported_"
if not game_id.startswith(prefix):
continue
numbers.append(int(game_id.replace(prefix, "", 1)))
game_number = max(numbers) + 1
self.game = Game(
{
"game_id": f"imported_{str(max(numbers) + 1)}",
"game_id": f"imported_{game_number}",
"hidden": False,
"source": "imported",
"added": int(time()),
"last_played": 0,
},
allow_side_effects=False,
)
else:
@@ -200,16 +205,44 @@ class DetailsWindow(Adw.Window):
self.game_cover.path,
)
shared.store.add_game(self.game, {}, run_pipeline=False)
self.game.save()
self.game.update()
if not self.game_cover.get_pixbuf():
SGDBSave({self.game})
# TODO: this is fucked up (less than before)
# Get a cover from SGDB if none is present
if not self.game_cover.get_texture():
self.game.set_loading(1)
sgdb_manager: SGDBManager = shared.store.managers[SGDBManager]
sgdb_manager.reset_cancellable()
sgdb_manager.process_game(self.game, {}, self.update_cover_callback)
self.game_cover.pictures.remove(self.cover)
self.close()
self.win.show_details_view(self.game)
def update_cover_callback(self, manager: SGDBManager):
# Set the game as not loading
self.game.set_loading(-1)
self.game.update()
# Handle errors that occured
for error in manager.collect_errors():
# On auth error, inform the user
if isinstance(error, FriendlyError):
create_dialog(
shared.win,
error.title,
error.subtitle,
"open_preferences",
_("Preferences"),
).connect("response", self.update_cover_error_response)
def update_cover_error_response(self, _widget, response):
if response == "open_preferences":
shared.win.get_application().on_preferences_action(page_name="sgdb")
def focus_executable(self, *_args):
self.set_focus(self.executable)
@@ -224,11 +257,11 @@ class DetailsWindow(Adw.Window):
except GLib.GError:
return
self.cover_button_delete_revealer.set_reveal_child(True)
self.cover_changed = True
def resize():
self.game_cover.new_cover(resize_cover(path))
if cover := resize_cover(path):
self.game_cover.new_cover(cover)
self.cover_button_delete_revealer.set_reveal_child(True)
self.cover_changed = True
self.toggle_loading()
self.toggle_loading()

View File

@@ -0,0 +1,28 @@
from threading import Lock
class ErrorProducer:
"""
A mixin for objects that produce errors.
Specifies the report_error and collect_errors methods in a thread-safe manner.
"""
errors: list[Exception] = None
errors_lock: Lock = None
def __init__(self) -> None:
self.errors = []
self.errors_lock = Lock()
def report_error(self, error: Exception):
"""Report an error"""
with self.errors_lock:
self.errors.append(error)
def collect_errors(self) -> list[Exception]:
"""Collect and remove the errors produced by the object"""
with self.errors_lock:
errors = self.errors.copy()
self.errors.clear()
return errors

View File

@@ -0,0 +1,47 @@
from typing import Iterable
class FriendlyError(Exception):
"""
An error that is supposed to be shown to the user in a nice format
Use `raise ... from ...` to preserve context.
"""
title_format: str
title_args: Iterable[str]
subtitle_format: str
subtitle_args: Iterable[str]
@property
def title(self) -> str:
"""Get the gettext translated error title"""
return self.title_format.format(self.title_args)
@property
def subtitle(self) -> str:
"""Get the gettext translated error subtitle"""
return self.subtitle_format.format(self.subtitle_args)
def __init__(
self,
title: str,
subtitle: str,
title_args: Iterable[str] = None,
subtitle_args: Iterable[str] = None,
) -> None:
"""Create a friendly error
:param str title: The error's title, translatable with gettext
:param str subtitle: The error's subtitle, translatable with gettext
"""
super().__init__()
if title is not None:
self.title_format = title
if subtitle is not None:
self.subtitle_format = subtitle
self.title_args = title_args if title_args else ()
self.subtitle_args = subtitle_args if subtitle_args else ()
def __str__(self) -> str:
return f"{self.title} - {self.subtitle}"

View File

@@ -17,19 +17,19 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
import os
import shlex
import subprocess
from pathlib import Path
from time import time
from gi.repository import Adw, Gtk
from gi.repository import Adw, GLib, GObject, Gtk
from . import shared
from .game_cover import GameCover
from src import shared
# pylint: disable=too-many-instance-attributes
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/game.ui")
class Game(Gtk.Box):
__gtype_name__ = "Game"
@@ -52,15 +52,16 @@ class Game(Gtk.Box):
executable = None
game_id = None
source = None
hidden = None
last_played = None
hidden = False
last_played = 0
name = None
developer = None
removed = None
blacklisted = None
removed = False
blacklisted = False
game_cover = None
version = 0
def __init__(self, data, **kwargs):
def __init__(self, data, allow_side_effects=True, **kwargs):
super().__init__(**kwargs)
self.win = shared.win
@@ -69,7 +70,8 @@ class Game(Gtk.Box):
self.update_values(data)
self.win.games[self.game_id] = self
if allow_side_effects:
self.win.games[self.game_id] = self
self.set_play_icon()
@@ -77,89 +79,23 @@ class Game(Gtk.Box):
self.add_controller(self.event_contoller_motion)
self.event_contoller_motion.connect("enter", self.toggle_play, False)
self.event_contoller_motion.connect("leave", self.toggle_play, None, None)
self.cover_button.connect("clicked", self.main_button_clicked, False)
self.play_button.connect("clicked", self.main_button_clicked, True)
shared.schema.connect("changed", self.schema_changed)
def update(self):
if self.get_parent():
self.get_parent().get_parent().remove(self)
if self.get_parent():
self.get_parent().set_child()
self.menu_button.set_menu_model(
self.hidden_game_options if self.hidden else self.game_options
)
self.title.set_label(self.name)
self.menu_button.get_popover().connect(
"notify::visible", self.toggle_play, None
)
self.menu_button.get_popover().connect(
"notify::visible", self.win.set_active_game, self
)
if self.game_id in self.win.game_covers:
self.game_cover = self.win.game_covers[self.game_id]
self.game_cover.add_picture(self.cover)
else:
self.game_cover = GameCover({self.cover}, self.get_cover_path())
self.win.game_covers[self.game_id] = self.game_cover
if (
self.win.stack.get_visible_child() == self.win.details_view
and self.win.active_game == self
):
self.win.show_details_view(self)
if not self.removed and not self.blacklisted:
if self.hidden:
self.win.hidden_library.append(self)
else:
self.win.library.append(self)
self.get_parent().set_focusable(False)
self.win.set_library_child()
def update_values(self, data):
for key, value in data.items():
# Convert executables to strings
if key == "executable" and isinstance(value, list):
value = shlex.join(value)
setattr(self, key, value)
def update(self):
self.emit("update-ready", {})
def save(self):
shared.games_dir.mkdir(parents=True, exist_ok=True)
attrs = (
"added",
"executable",
"game_id",
"source",
"hidden",
"last_played",
"name",
"developer",
"removed",
"blacklisted",
"version",
)
# TODO: remove for 2.0
attrs = list(attrs)
if not self.removed:
attrs.remove("removed")
if not self.blacklisted:
attrs.remove("blacklisted")
json.dump(
{attr: getattr(self, attr) for attr in attrs if attr},
(shared.games_dir / f"{self.game_id}.json").open("w"),
indent=4,
sort_keys=True,
)
self.update()
self.emit("save-ready", {})
def create_toast(self, title, action=None):
toast = Adw.Toast.new(title.format(self.name))
@@ -180,19 +116,16 @@ class Game(Gtk.Box):
def launch(self):
self.last_played = int(time())
self.save()
string = (
self.executable
if isinstance(self.executable, str)
else shlex.join(self.executable)
)
self.update()
args = (
"flatpak-spawn --host /bin/sh -c " + shlex.quote(string) # Flatpak
"flatpak-spawn --host /bin/sh -c " + shlex.quote(self.executable) # Flatpak
if os.getenv("FLATPAK_ID") == shared.APP_ID
else string # Others
else self.executable # Others
)
logging.info("Starting %s: %s", self.name, str(args))
# pylint: disable=consider-using-with
subprocess.Popen(
args,
cwd=Path.home(),
@@ -210,6 +143,7 @@ class Game(Gtk.Box):
def toggle_hidden(self, toast=True):
self.hidden = not self.hidden
self.save()
self.update()
if self.win.stack.get_visible_child() == self.win.details_view:
self.win.on_go_back_action()
@@ -217,7 +151,9 @@ class Game(Gtk.Box):
if toast:
self.create_toast(
# The variable is the title of the game
(_("{} hidden") if self.hidden else _("{} unhidden")).format(self.name),
(_("{} hidden") if self.hidden else _("{} unhidden")).format(
GLib.markup_escape_text(self.name)
),
"hide",
)
@@ -225,12 +161,15 @@ class Game(Gtk.Box):
# Add "removed=True" to the game properties so it can be deleted on next init
self.removed = True
self.save()
self.update()
if self.win.stack.get_visible_child() == self.win.details_view:
self.win.on_go_back_action()
# The variable is the title of the game
self.create_toast(_("{} removed").format(self.name), "remove")
self.create_toast(
_("{} removed").format(GLib.markup_escape_text(self.name)), "remove"
)
def set_loading(self, state):
self.loading += state
@@ -271,3 +210,11 @@ class Game(Gtk.Box):
def schema_changed(self, _settings, key):
if key == "cover-launches-game":
self.set_play_icon()
@GObject.Signal(name="update-ready", arg_types=[object])
def update_ready(self, _additional_data) -> None:
"""Signal emitted when the game needs updating"""
@GObject.Signal(name="save-ready", arg_types=[object])
def save_ready(self, _additional_data) -> None:
"""Signal emitted when the game needs saving"""

View File

@@ -17,22 +17,25 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import GdkPixbuf, Gio, GLib
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
from PIL import Image, ImageFilter, ImageStat
from . import shared
from src import shared
class GameCover:
pixbuf = None
texture = None
blurred = None
luminance = None
path = None
animation = None
anim_iter = None
placeholder_pixbuf = GdkPixbuf.Pixbuf.new_from_resource_at_scale(
shared.PREFIX + "/library_placeholder.svg", 400, 600, False
placeholder = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder.svg"
)
placeholder_small = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder_small.svg"
)
def __init__(self, pictures, path=None):
@@ -51,7 +54,7 @@ class GameCover:
def new_cover(self, path=None):
self.animation = None
self.pixbuf = None
self.texture = None
self.blurred = None
self.luminance = None
self.path = path
@@ -61,13 +64,17 @@ class GameCover:
task = Gio.Task.new()
task.run_in_thread(self.create_func(self.path))
else:
self.pixbuf = GdkPixbuf.Pixbuf.new_from_file(str(path))
self.texture = Gdk.Texture.new_from_filename(str(path))
if not self.animation:
self.set_pixbuf(self.pixbuf)
self.set_texture(self.texture)
def get_pixbuf(self):
return self.animation.get_static_image() if self.animation else self.pixbuf
def get_texture(self):
return (
Gdk.Texture.new_for_pixbuf(self.animation.get_static_image())
if self.animation
else self.texture
)
def get_blurred(self):
if not self.blurred:
@@ -82,7 +89,7 @@ class GameCover:
tmp_path = Gio.File.new_tmp(None)[0].get_path()
image.save(tmp_path, "tiff", compression=None)
self.blurred = GdkPixbuf.Pixbuf.new_from_file(tmp_path)
self.blurred = Gdk.Texture.new_from_filename(tmp_path)
stat = ImageStat.Stat(image.convert("L"))
@@ -92,20 +99,17 @@ class GameCover:
(stat.mean[0] + stat.extrema[0][1]) / 510,
)
else:
self.blurred = GdkPixbuf.Pixbuf.new_from_resource_at_scale(
shared.PREFIX + "/library_placeholder.svg", 2, 2, False
)
self.luminance = (0.1, 0.8)
self.blurred = self.placeholder_small
self.luminance = (0.3, 0.5)
return self.blurred
def add_picture(self, picture):
self.pictures.add(picture)
if not self.animation:
self.set_pixbuf(self.pixbuf)
self.set_texture(self.texture)
def set_pixbuf(self, pixbuf):
def set_texture(self, texture):
self.pictures.discard(
picture for picture in self.pictures if not picture.is_visible()
)
@@ -113,15 +117,13 @@ class GameCover:
self.animation = None
else:
for picture in self.pictures:
if not pixbuf:
pixbuf = self.placeholder_pixbuf
picture.set_pixbuf(pixbuf)
picture.set_paintable(texture or self.placeholder)
def update_animation(self, data):
if self.animation == data[1]:
self.anim_iter.advance()
self.set_pixbuf(self.anim_iter.get_pixbuf())
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf()))
delay_time = self.anim_iter.get_delay_time()
GLib.timeout_add(

305
src/importer/importer.py Normal file
View File

@@ -0,0 +1,305 @@
# importer.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from gi.repository import Adw, GLib, Gtk
from src import shared
from src.errors.error_producer import ErrorProducer
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.importer.sources.source import Source
from src.store.managers.async_manager import AsyncManager
from src.store.pipeline import Pipeline
from src.utils.task import Task
# pylint: disable=too-many-instance-attributes
class Importer(ErrorProducer):
"""A class in charge of scanning sources for games"""
progressbar = None
import_statuspage = None
import_dialog = None
summary_toast = None
sources: set[Source] = None
n_source_tasks_created: int = 0
n_source_tasks_done: int = 0
n_pipelines_done: int = 0
game_pipelines: set[Pipeline] = None
def __init__(self):
super().__init__()
self.game_pipelines = set()
self.sources = set()
@property
def n_games_added(self):
return sum(
1 if not (pipeline.game.blacklisted or pipeline.game.removed) else 0
for pipeline in self.game_pipelines
)
@property
def pipelines_progress(self):
progress = sum(pipeline.progress for pipeline in self.game_pipelines)
try:
progress = progress / len(self.game_pipelines)
except ZeroDivisionError:
progress = 1
return progress
@property
def finished(self):
return (
self.n_source_tasks_created == self.n_source_tasks_done
and len(self.game_pipelines) == self.n_pipelines_done
)
def add_source(self, source):
self.sources.add(source)
def run(self):
"""Use several Gio.Task to import games from added sources"""
self.create_dialog()
# Collect all errors and reset the cancellables for the managers
# - Only one importer exists at any given time
# - Every import starts fresh
self.collect_errors()
for manager in shared.store.managers.values():
manager.collect_errors()
if isinstance(manager, AsyncManager):
manager.reset_cancellable()
for source in self.sources:
logging.debug("Importing games from source %s", source.id)
task = Task.new(None, None, self.source_callback, (source,))
self.n_source_tasks_created += 1
task.set_task_data((source,))
task.run_in_thread(self.source_task_thread_func)
self.progress_changed_callback()
def create_dialog(self):
"""Create the import dialog"""
self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12)
self.import_statuspage = Adw.StatusPage(
title=_("Importing Games…"),
child=self.progressbar,
)
self.import_dialog = Adw.Window(
content=self.import_statuspage,
modal=True,
default_width=350,
default_height=-1,
transient_for=shared.win,
deletable=False,
)
self.import_dialog.present()
def source_task_thread_func(self, _task, _obj, data, _cancellable):
"""Source import task code"""
source: Source
source, *_rest = data
# Early exit if not installed
if not source.is_available:
logging.info("Source %s skipped, not installed", source.id)
return
logging.info("Scanning source %s", source.id)
# Initialize source iteration
iterator = iter(source)
# Get games from source
while True:
# Handle exceptions raised when iterating
try:
iteration_result = next(iterator)
except StopIteration:
break
except Exception as error: # pylint: disable=broad-exception-caught
logging.exception("%s in %s", type(error).__name__, source.id)
self.report_error(error)
continue
# Handle the result depending on its type
if isinstance(iteration_result, Game):
game = iteration_result
additional_data = {}
elif isinstance(iteration_result, tuple):
game, additional_data = iteration_result
elif iteration_result is None:
continue
else:
# Warn source implementers that an invalid type was produced
# Should not happen on production code
logging.warning(
"%s produced an invalid iteration return type %s",
source.id,
type(iteration_result),
)
continue
# Register game
pipeline: Pipeline = shared.store.add_game(game, additional_data)
if pipeline is not None:
logging.info("Imported %s (%s)", game.name, game.game_id)
pipeline.connect("advanced", self.pipeline_advanced_callback)
self.game_pipelines.add(pipeline)
def update_progressbar(self):
"""Update the progressbar to show the percentage of game pipelines done"""
self.progressbar.set_fraction(self.pipelines_progress)
def source_callback(self, _obj, _result, data):
"""Callback executed when a source is fully scanned"""
source, *_rest = data
logging.debug("Import done for source %s", source.id)
self.n_source_tasks_done += 1
self.progress_changed_callback()
def pipeline_advanced_callback(self, pipeline: Pipeline):
"""Callback called when a pipeline for a game has advanced"""
if pipeline.is_done:
self.n_pipelines_done += 1
self.progress_changed_callback()
def progress_changed_callback(self):
"""
Callback called when the import process has progressed
Triggered when:
* All sources have been started
* A source finishes
* A pipeline finishes
"""
self.update_progressbar()
if self.finished:
self.import_callback()
def import_callback(self):
"""Callback called when importing has finished"""
logging.info("Import done")
self.import_dialog.close()
self.summary_toast = self.create_summary_toast()
self.create_error_dialog()
def create_error_dialog(self):
"""Dialog containing all errors raised by importers"""
# Collect all errors that happened in the importer and the managers
errors: list[Exception] = []
errors.extend(self.collect_errors())
for manager in shared.store.managers.values():
errors.extend(manager.collect_errors())
# Filter out non friendly errors
errors = set(
tuple(
(error.title, error.subtitle)
for error in (
filter(lambda error: isinstance(error, FriendlyError), errors)
)
)
)
# No error to display
if not errors:
self.timeout_toast()
return
# Create error dialog
dialog = Adw.MessageDialog()
dialog.set_heading(_("Warning"))
dialog.add_response("close", _("Dismiss"))
dialog.add_response("open_preferences_import", _("Preferences"))
dialog.set_default_response("open_preferences_import")
dialog.connect("response", self.dialog_response_callback)
dialog.set_transient_for(shared.win)
if len(errors) == 1:
dialog.set_heading((error := next(iter(errors)))[0])
dialog.set_body(error[1])
else:
# Display the errors in a list
list_box = Gtk.ListBox()
list_box.set_selection_mode(Gtk.SelectionMode.NONE)
list_box.set_css_classes(["boxed-list"])
list_box.set_margin_top(8)
for error in errors:
row = Adw.ActionRow.new()
row.set_title(error[0])
row.set_subtitle(error[1])
list_box.append(row)
dialog.set_body(_("The following errors occured during import:"))
dialog.set_extra_child(list_box)
dialog.present()
def create_summary_toast(self):
"""N games imported toast"""
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
if self.n_games_added == 0:
toast.set_title(_("No new games found"))
toast.set_button_label(_("Preferences"))
toast.connect(
"button-clicked",
self.dialog_response_callback,
"open_preferences",
"import",
)
elif self.n_games_added == 1:
toast.set_title(_("1 game imported"))
elif self.n_games_added > 1:
# The variable is the number of games
toast.set_title(_("{} games imported").format(self.n_games_added))
shared.win.toast_overlay.add_toast(toast)
return toast
def open_preferences(self, page=None, expander_row=None):
return shared.win.get_application().on_preferences_action(
page_name=page, expander_row=expander_row
)
def timeout_toast(self, *_args):
"""Manually timeout the toast after the user has dismissed all warnings"""
GLib.timeout_add_seconds(5, self.summary_toast.dismiss)
def dialog_response_callback(self, _widget, response, *args):
"""Handle after-import dialogs callback"""
logging.debug("After-import dialog response: %s (%s)", response, str(args))
if response == "open_preferences":
self.open_preferences(*args)
elif response == "open_preferences_import":
self.open_preferences(*args).connect("close-request", self.timeout_toast)
else:
self.timeout_toast()

View File

@@ -0,0 +1,101 @@
# bottles_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from time import time
import yaml
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import (
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
class BottlesSourceIterator(SourceIterator):
source: "BottlesSource"
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
data = self.source.data_location["library.yml"].read_text("utf-8")
library: dict = yaml.safe_load(data)
for entry in library.values():
# Build game
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"name": entry["name"],
"game_id": self.source.game_id_format.format(game_id=entry["id"]),
"executable": self.source.executable_format.format(
bottle_name=entry["bottle"]["name"], game_name=entry["name"]
),
}
game = Game(values, allow_side_effects=False)
# Get official cover path
try:
# This will not work if both Cartridges and Bottles are installed via Flatpak
# as Cartridges can't access directories picked via Bottles' file picker portal
bottles_location = Path(
yaml.safe_load(
self.source.data_location["data.yml"].read_text("utf-8")
)["custom_bottles_path"]
)
except (FileNotFoundError, KeyError):
bottles_location = self.source.data_location.root / "bottles"
bottle_path = entry["bottle"]["path"]
additional_data = {}
if entry["thumbnail"]:
image_name = entry["thumbnail"].split(":")[1]
image_path = bottles_location / bottle_path / "grids" / image_name
additional_data = {"local_image_path": image_path}
# Produce game
yield (game, additional_data)
class BottlesSource(URLExecutableSource):
"""Generic Bottles source"""
name = "Bottles"
iterator_class = BottlesSourceIterator
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = set(("linux",))
data_location = Location(
schema_key="bottles-location",
candidates=(
"~/.var/app/com.usebottles.bottles/data/bottles/",
shared.data_dir / "bottles/",
),
paths={
"library.yml": (False, "library.yml"),
"data.yml": (False, "data.yml"),
},
)

View File

@@ -0,0 +1,156 @@
# heroic_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from hashlib import sha256
from json import JSONDecodeError
from time import time
from typing import Optional, TypedDict
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import (
URLExecutableSource,
SourceIterationResult,
SourceIterator,
)
class HeroicLibraryEntry(TypedDict):
app_name: str
installed: Optional[bool]
runner: str
title: str
developer: str
art_square: str
class HeroicSubSource(TypedDict):
service: str
path: tuple[str]
class HeroicSourceIterator(SourceIterator):
source: "HeroicSource"
sub_sources: dict[str, HeroicSubSource] = {
"sideload": {
"service": "sideload",
"path": ("sideload_apps", "library.json"),
},
"legendary": {
"service": "epic",
"path": ("store_cache", "legendary_library.json"),
},
"gog": {
"service": "gog",
"path": ("store_cache", "gog_library.json"),
},
}
def game_from_library_entry(
self, entry: HeroicLibraryEntry
) -> SourceIterationResult:
"""Helper method used to build a Game from a Heroic library entry"""
# Skip games that are not installed
if not entry["is_installed"]:
return None
# Build game
app_name = entry["app_name"]
runner = entry["runner"]
service = self.sub_sources[runner]["service"]
values = {
"version": shared.SPEC_VERSION,
"source": self.source.id,
"added": int(time()),
"name": entry["title"],
"developer": entry["developer"],
"game_id": self.source.game_id_format.format(
service=service, game_id=app_name
),
"executable": self.source.executable_format.format(app_name=app_name),
}
game = Game(values, allow_side_effects=False)
# Get the image path from the heroic cache
# Filenames are derived from the URL that heroic used to get the file
uri: str = entry["art_square"]
if service == "epic":
uri += "?h=400&resize=1&w=300"
digest = sha256(uri.encode()).hexdigest()
image_path = self.source.config_location.root / "images-cache" / digest
additional_data = {"local_image_path": image_path}
return (game, additional_data)
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games from all the Heroic sub-sources"""
for sub_source in self.sub_sources.values():
# Skip disabled sub-sources
if not shared.schema.get_boolean("heroic-import-" + sub_source["service"]):
continue
# Load games from JSON
file = self.source.config_location.root.joinpath(*sub_source["path"])
try:
library = json.load(file.open())["library"]
except (JSONDecodeError, OSError, KeyError):
# Invalid library.json file, skip it
logging.warning("Couldn't open Heroic file: %s", str(file))
continue
for entry in library:
try:
result = self.game_from_library_entry(entry)
except KeyError:
# Skip invalid games
logging.warning("Invalid Heroic game skipped in %s", str(file))
continue
yield result
class HeroicSource(URLExecutableSource):
"""Generic heroic games launcher source"""
name = "Heroic"
iterator_class = HeroicSourceIterator
url_format = "heroic://launch/{app_name}"
available_on = set(("linux", "win32"))
config_location = Location(
schema_key="heroic-location",
candidates=(
"~/.var/app/com.heroicgameslauncher.hgl/config/heroic/",
shared.config_dir / "heroic/",
"~/.config/heroic/",
shared.appdata_dir / "heroic/",
),
paths={
"config.json": (False, "config.json"),
},
)
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.name.lower() + "_{service}_{game_id}"

View File

@@ -0,0 +1,95 @@
# itch_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from shutil import rmtree
from sqlite3 import connect
from time import time
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import (
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
from src.utils.sqlite import copy_db
class ItchSourceIterator(SourceIterator):
source: "ItchSource"
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
# Query the database
db_request = """
SELECT
games.id,
games.title,
games.cover_url,
games.still_cover_url,
caves.id
FROM
'caves'
INNER JOIN
'games'
ON
caves.game_id = games.id
;
"""
db_path = copy_db(self.source.config_location["butler.db"])
connection = connect(db_path)
cursor = connection.execute(db_request)
# Create games from the db results
for row in cursor:
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"source": self.source.id,
"name": row[1],
"game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.executable_format.format(cave_id=row[4]),
}
additional_data = {"online_cover_url": row[3] or row[2]}
game = Game(values, allow_side_effects=False)
yield (game, additional_data)
# Cleanup
rmtree(str(db_path.parent))
class ItchSource(URLExecutableSource):
name = "Itch"
iterator_class = ItchSourceIterator
url_format = "itch://caves/{cave_id}/launch"
available_on = set(("linux", "win32"))
config_location = Location(
schema_key="itch-location",
candidates=(
"~/.var/app/io.itch.itch/config/itch/",
shared.config_dir / "itch/",
"~/.config/itch/",
shared.appdata_dir / "itch/",
),
paths={"butler.db": (False, "db/butler.db")},
)

View File

@@ -0,0 +1,104 @@
# legendary_source.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
from json import JSONDecodeError
from time import time
from typing import Generator
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
class LegendarySourceIterator(SourceIterator):
source: "LegendarySource"
def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
# Skip non-games
if entry["is_dlc"]:
return None
# Build game
app_name = entry["app_name"]
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"source": self.source.id,
"name": entry["title"],
"game_id": self.source.game_id_format.format(game_id=app_name),
"executable": self.source.executable_format.format(app_name=app_name),
}
data = {}
# Get additional metadata from file (optional)
metadata_file = self.source.data_location["metadata"] / f"{app_name}.json"
try:
metadata = json.load(metadata_file.open())
values["developer"] = metadata["metadata"]["developer"]
for image_entry in metadata["metadata"]["keyImages"]:
if image_entry["type"] == "DieselGameBoxTall":
data["online_cover_url"] = image_entry["url"]
break
except (JSONDecodeError, OSError, KeyError):
pass
game = Game(values, allow_side_effects=False)
return (game, data)
def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
# Open library
file = self.source.data_location["installed.json"]
try:
library: dict = json.load(file.open())
except (JSONDecodeError, OSError):
logging.warning("Couldn't open Legendary file: %s", str(file))
return
# Generate games from library
for entry in library.values():
try:
result = self.game_from_library_entry(entry)
except KeyError as error:
# Skip invalid games
logging.warning(
"Invalid Legendary game skipped in %s", str(file), exc_info=error
)
continue
yield result
class LegendarySource(Source):
name = "Legendary"
executable_format = "legendary launch {app_name}"
available_on = set(("linux", "win32"))
iterator_class = LegendarySourceIterator
data_location: Location = Location(
schema_key="legendary-location",
candidates=(
shared.config_dir / "legendary/",
"~/.config/legendary",
),
paths={
"installed.json": (False, "installed.json"),
"metadata": (True, "metadata"),
},
)

View File

@@ -0,0 +1,84 @@
import logging
from pathlib import Path
from typing import Callable, Mapping, Iterable
from os import PathLike
from src import shared
PathSegment = str | PathLike | Path
PathSegments = Iterable[PathSegment]
Candidate = PathSegments | Callable[[], PathSegments]
class UnresolvableLocationError(Exception):
pass
class Location:
"""
Class representing a filesystem location
* A location may have multiple candidate roots
* The path in the schema is always favored
* From the candidate root, multiple subpaths should exist for it to be valid
* When resolved, the schema is updated with the picked chosen
"""
schema_key: str
candidates: Iterable[Candidate]
paths: Mapping[str, tuple[bool, PathSegments]]
root: Path = None
def __init__(
self,
schema_key: str,
candidates: Iterable[Candidate],
paths: Mapping[str, tuple[bool, PathSegments]],
) -> None:
super().__init__()
self.schema_key = schema_key
self.candidates = candidates
self.paths = paths
def check_candidate(self, candidate: Path) -> bool:
"""Check if a candidate root has the necessary files and directories"""
for type_is_dir, subpath in self.paths.values():
subpath = Path(candidate) / Path(subpath)
if type_is_dir:
if not subpath.is_dir():
return False
else:
if not subpath.is_file():
return False
return True
def resolve(self) -> None:
"""Choose a root path from the candidates for the location.
If none fits, raise a UnresolvableLocationError"""
if self.root is not None:
return
# Get the schema candidate
schema_candidate = shared.schema.get_string(self.schema_key)
# Find the first matching candidate
for candidate in (schema_candidate, *self.candidates):
candidate = Path(candidate).expanduser()
if not self.check_candidate(candidate):
continue
self.root = candidate
break
else:
# No good candidate found
raise UnresolvableLocationError()
# Update the schema with the found candidate
value = str(candidate)
shared.schema.set_string(self.schema_key, value)
logging.debug("Resolved value for schema key %s: %s", self.schema_key, value)
def __getitem__(self, key: str):
"""Get the computed path from its key for the location"""
self.resolve()
return self.root / self.paths[key][1]

View File

@@ -0,0 +1,121 @@
# lutris_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from shutil import rmtree
from sqlite3 import connect
from time import time
from src import shared
from src.game import Game
from src.importer.sources.location import Location
from src.importer.sources.source import (
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
from src.utils.sqlite import copy_db
class LutrisSourceIterator(SourceIterator):
source: "LutrisSource"
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
# Query the database
request = """
SELECT id, name, slug, runner, hidden
FROM 'games'
WHERE
name IS NOT NULL
AND slug IS NOT NULL
AND configPath IS NOT NULL
AND installed
AND (runner IS NOT "steam" OR :import_steam)
;
"""
params = {"import_steam": shared.schema.get_boolean("lutris-import-steam")}
db_path = copy_db(self.source.data_location["pga.db"])
connection = connect(db_path)
cursor = connection.execute(request, params)
# Create games from the DB results
for row in cursor:
# Create game
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"hidden": row[4],
"name": row[1],
"source": f"{self.source.id}_{row[3]}",
"game_id": self.source.game_id_format.format(
game_id=row[2], game_internal_id=row[0]
),
"executable": self.source.executable_format.format(game_id=row[2]),
}
game = Game(values, allow_side_effects=False)
# Get official image path
image_path = self.source.cache_location["coverart"] / f"{row[2]}.jpg"
additional_data = {"local_image_path": image_path}
# Produce game
yield (game, additional_data)
# Cleanup
rmtree(str(db_path.parent))
class LutrisSource(URLExecutableSource):
"""Generic lutris source"""
name = "Lutris"
iterator_class = LutrisSourceIterator
url_format = "lutris:rungameid/{game_id}"
available_on = set(("linux",))
# FIXME possible bug: location picks ~/.var... and cache_lcoation picks ~/.local...
data_location = Location(
schema_key="lutris-location",
candidates=(
"~/.var/app/net.lutris.Lutris/data/lutris/",
shared.data_dir / "lutris/",
"~/.local/share/lutris/",
),
paths={
"pga.db": (False, "pga.db"),
},
)
cache_location = Location(
schema_key="lutris-cache-location",
candidates=(
"~/.var/app/net.lutris.Lutris/cache/lutris/",
shared.cache_dir / "lutris/",
"~/.cache/lutris",
),
paths={
"coverart": (True, "coverart"),
},
)
@property
def game_id_format(self):
return super().game_id_format + "_{game_internal_id}"

View File

@@ -0,0 +1,143 @@
# source.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import sys
from abc import abstractmethod
from collections.abc import Iterable, Iterator
from typing import Any, Generator, Optional
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.importer.sources.location import Location, UnresolvableLocationError
# Type of the data returned by iterating on a Source
SourceIterationResult = None | Game | tuple[Game, tuple[Any]]
class SourceIterator(Iterator):
"""Data producer for a source of games"""
source: "Source" = None
generator: Generator = None
def __init__(self, source: "Source") -> None:
super().__init__()
self.source = source
self.generator = self.generator_builder()
def __iter__(self) -> "SourceIterator":
return self
def __next__(self) -> SourceIterationResult:
return next(self.generator)
@abstractmethod
def generator_builder(self) -> Generator[SourceIterationResult, None, None]:
"""
Method that returns a generator that produces games
* Should be implemented as a generator method
* May yield `None` when an iteration hasn't produced a game
* In charge of handling per-game errors
* Returns when exhausted
"""
class Source(Iterable):
"""Source of games. E.g an installed app with a config file that lists game directories"""
name: str
variant: str = None
available_on: set[str] = set()
data_location: Optional[Location] = None
cache_location: Optional[Location] = None
config_location: Optional[Location] = None
iterator_class: type[SourceIterator]
@property
def full_name(self) -> str:
"""The source's full name"""
full_name_ = self.name
if self.variant is not None:
full_name_ += f" ({self.variant})"
return full_name_
@property
def id(self) -> str: # pylint: disable=invalid-name
"""The source's identifier"""
id_ = self.name.lower()
if self.variant is not None:
id_ += f"_{self.variant.lower()}"
return id_
@property
def game_id_format(self) -> str:
"""The string format used to construct game IDs"""
return self.name.lower() + "_{game_id}"
@property
def is_available(self):
return sys.platform in self.available_on
@property
@abstractmethod
def executable_format(self) -> str:
"""The executable format used to construct game executables"""
def __iter__(self) -> SourceIterator:
"""Get an iterator for the source"""
for location_name in (
locations := {
"data": _("Data"),
"cache": _("Cache"),
"config": _("Configuration"),
}.keys()
):
location = getattr(self, f"{location_name}_location", None)
if location is None:
continue
try:
location.resolve()
except UnresolvableLocationError as error:
raise FriendlyError(
# The variables are the type of location (eg. cache) and the source's name
_("Invalid {} Location for {{}}").format(locations[location_name]),
_("Change it or disable the source in preferences"),
(self.name,),
(self.name,),
) from error
return self.iterator_class(self)
# pylint: disable=abstract-method
class URLExecutableSource(Source):
"""Source class that use custom URLs to start games"""
url_format: str
@property
def executable_format(self) -> str:
match sys.platform:
case "win32":
return "start " + self.url_format
case "linux":
return "xdg-open " + self.url_format
case other:
raise NotImplementedError(
f"No URL handler command available for {other}"
)

View File

@@ -0,0 +1,128 @@
# steam_source.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import re
from pathlib import Path
from time import time
from typing import Iterable
from src import shared
from src.game import Game
from src.importer.sources.source import (
SourceIterationResult,
SourceIterator,
URLExecutableSource,
)
from src.utils.steam import SteamFileHelper, SteamInvalidManifestError
from src.importer.sources.location import Location
class SteamSourceIterator(SourceIterator):
source: "SteamSource"
def get_manifest_dirs(self) -> Iterable[Path]:
"""Get dirs that contain steam app manifests"""
libraryfolders_path = self.source.data_location["libraryfolders.vdf"]
with open(libraryfolders_path, "r", encoding="utf-8") as file:
contents = file.read()
return [
Path(path) / "steamapps"
for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE)
]
def get_manifests(self) -> Iterable[Path]:
"""Get app manifests"""
manifests = set()
for steamapps_dir in self.get_manifest_dirs():
if not steamapps_dir.is_dir():
continue
manifests.update(
[
manifest
for manifest in steamapps_dir.glob("appmanifest_*.acf")
if manifest.is_file()
]
)
return manifests
def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games"""
appid_cache = set()
manifests = self.get_manifests()
for manifest in manifests:
# Get metadata from manifest
steam = SteamFileHelper()
try:
local_data = steam.get_manifest_data(manifest)
except (OSError, SteamInvalidManifestError):
continue
# Skip non installed games
installed_mask = 4
if not int(local_data["stateflags"]) & installed_mask:
continue
# Skip duplicate appids
appid = local_data["appid"]
if appid in appid_cache:
continue
appid_cache.add(appid)
# Build game from local data
values = {
"version": shared.SPEC_VERSION,
"added": int(time()),
"name": local_data["name"],
"source": self.source.id,
"game_id": self.source.game_id_format.format(game_id=appid),
"executable": self.source.executable_format.format(game_id=appid),
}
game = Game(values, allow_side_effects=False)
# Add official cover image
image_path = (
self.source.data_location["librarycache"]
/ f"{appid}_library_600x900.jpg"
)
additional_data = {"local_image_path": image_path, "steam_appid": appid}
# Produce game
yield (game, additional_data)
class SteamSource(URLExecutableSource):
name = "Steam"
available_on = set(("linux", "win32"))
iterator_class = SteamSourceIterator
url_format = "steam://rungameid/{game_id}"
data_location = Location(
schema_key="steam-location",
candidates=(
"~/.var/app/com.valvesoftware.Steam/data/Steam/",
shared.data_dir / "Steam/",
"~/.steam/",
shared.programfiles32_dir / "Steam",
),
paths={
"libraryfolders.vdf": (False, "steamapps/libraryfolders.vdf"),
"librarycache": (True, "appcache/librarycache"),
},
)

View File

@@ -1,108 +0,0 @@
# bottles_importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from time import time
import yaml
from . import shared
from .check_install import check_install
def bottles_installed(path=None):
location_key = "bottles-location"
check = "library.yml"
locations = (
(path,)
if path
else (
Path(shared.schema.get_string(location_key)).expanduser(),
Path.home() / ".var/app/com.usebottles.bottles/data/bottles",
shared.data_dir / "bottles",
)
)
bottles_dir = check_install(check, locations, (shared.schema, location_key))
return bottles_dir
def bottles_importer():
bottles_dir = bottles_installed()
if not bottles_dir:
return
current_time = int(time())
data = (bottles_dir / "library.yml").read_text("utf-8")
library = yaml.safe_load(data)
importer = shared.importer
importer.total_queue += len(library)
importer.queue += len(library)
for game in library:
game = library[game]
values = {}
values["game_id"] = f'bottles_{game["id"]}'
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
importer.save_game()
continue
values["name"] = game["name"]
values["executable"] = [
"xdg-open",
f'bottles:run/{game["bottle"]["name"]}/{game["name"]}',
]
values["hidden"] = False
values["source"] = "bottles"
values["added"] = current_time
values["last_played"] = 0
# This will not work if both Cartridges and Bottles are installed via Flatpak
# as Cartridges can't access directories picked via Bottles' file picker portal
try:
bottles_location = Path(
yaml.safe_load((bottles_dir / "data.yml").read_text("utf-8"))[
"custom_bottles_path"
]
)
except (FileNotFoundError, KeyError):
bottles_location = bottles_dir / "bottles"
if game["thumbnail"]:
grid_path = (
bottles_location
/ game["bottle"]["path"]
/ "grids"
/ game["thumbnail"].split(":")[1]
)
importer.save_game(
values,
grid_path if game["thumbnail"] and grid_path.is_file() else None,
)

View File

@@ -1,201 +0,0 @@
# heroic_importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import os
from hashlib import sha256
from pathlib import Path
from time import time
from . import shared
from .check_install import check_install
def heroic_installed(path=None):
location_key = "heroic-location"
check = "config.json"
locations = (
(path,)
if path
else (
Path(shared.schema.get_string(location_key)).expanduser(),
Path.home() / ".var/app/com.heroicgameslauncher.hgl/config/heroic",
shared.config_dir / "heroic",
)
)
if os.name == "nt" and not path:
locations += (Path(os.getenv("appdata")) / "heroic",)
heroic_dir = check_install(check, locations, (shared.schema, location_key))
return heroic_dir
def heroic_importer():
heroic_dir = heroic_installed()
if not heroic_dir:
return
current_time = int(time())
importer = shared.importer
# Import Epic games
if not shared.schema.get_boolean("heroic-import-epic"):
pass
elif (heroic_dir / "store_cache" / "legendary_library.json").is_file():
library = json.load(
(heroic_dir / "store_cache" / "legendary_library.json").open()
)
try:
for game in library["library"]:
if not game["is_installed"]:
continue
importer.total_queue += 1
importer.queue += 1
values = {}
app_name = game["app_name"]
values["game_id"] = f"heroic_epic_{app_name}"
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
importer.save_game()
continue
values["name"] = game["title"]
values["developer"] = game["developer"]
values["executable"] = (
["start", f"heroic://launch/{app_name}"]
if os.name == "nt"
else ["xdg-open", f"heroic://launch/{app_name}"]
)
values["hidden"] = False
values["source"] = "heroic_epic"
values["added"] = current_time
values["last_played"] = 0
image_path = (
heroic_dir
/ "images-cache"
/ sha256(
(f'{game["art_square"]}?h=400&resize=1&w=300').encode()
).hexdigest()
)
importer.save_game(values, image_path if image_path.is_file() else None)
except KeyError:
pass
# Import GOG games
if not shared.schema.get_boolean("heroic-import-gog"):
pass
elif (heroic_dir / "gog_store" / "installed.json").is_file() and (
heroic_dir / "store_cache" / "gog_library.json"
).is_file():
installed = json.load((heroic_dir / "gog_store" / "installed.json").open())
importer.total_queue += len(installed["installed"])
importer.queue += len(installed["installed"])
for item in installed["installed"]:
values = {}
app_name = item["appName"]
values["game_id"] = f"heroic_gog_{app_name}"
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
importer.save_game()
continue
# Get game title and developer from gog_library.json as they are not present in installed.json
library = json.load(
(heroic_dir / "store_cache" / "gog_library.json").open()
)
for game in library["games"]:
if game["app_name"] == app_name:
values["developer"] = game["developer"]
values["name"] = game["title"]
image_path = (
heroic_dir
/ "images-cache"
/ sha256(game["art_square"].encode()).hexdigest()
)
values["executable"] = (
["start", f"heroic://launch/{app_name}"]
if os.name == "nt"
else ["xdg-open", f"heroic://launch/{app_name}"]
)
values["hidden"] = False
values["source"] = "heroic_gog"
values["added"] = current_time
values["last_played"] = 0
importer.save_game(values, image_path if image_path.is_file() else None)
# Import sideloaded games
if not shared.schema.get_boolean("heroic-import-sideload"):
pass
elif (heroic_dir / "sideload_apps" / "library.json").is_file():
library = json.load((heroic_dir / "sideload_apps" / "library.json").open())
importer.total_queue += len(library["games"])
importer.queue += len(library["games"])
for item in library["games"]:
values = {}
app_name = item["app_name"]
values["game_id"] = f"heroic_sideload_{app_name}"
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
importer.save_game()
continue
values["name"] = item["title"]
values["executable"] = (
["start", f"heroic://launch/{app_name}"]
if os.name == "nt"
else ["xdg-open", f"heroic://launch/{app_name}"]
)
values["hidden"] = False
values["source"] = "heroic_sideload"
values["added"] = current_time
values["last_played"] = 0
image_path = (
heroic_dir
/ "images-cache"
/ sha256(item["art_square"].encode()).hexdigest()
)
importer.save_game(values, image_path if image_path.is_file() else None)

View File

@@ -1,189 +0,0 @@
# itch_importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
from pathlib import Path
from shutil import copyfile
from sqlite3 import connect
from time import time
import requests
from gi.repository import GdkPixbuf, Gio
from . import shared
from .check_install import check_install
from .save_cover import resize_cover
def get_game(task, current_time, row):
values = {}
values["game_id"] = f"itch_{row[0]}"
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
task.return_value((None, None))
return
values["added"] = current_time
values["executable"] = (
["start", f"itch://caves/{row[4]}/launch"]
if os.name == "nt"
else ["xdg-open", f"itch://caves/{row[4]}/launch"]
)
values["hidden"] = False
values["last_played"] = 0
values["name"] = row[1]
values["source"] = "itch"
if row[3] or row[2]:
tmp_file = Gio.File.new_tmp()[0]
try:
with requests.get(row[3] or row[2], timeout=5) as cover:
cover.raise_for_status()
Path(tmp_file.get_path()).write_bytes(cover.content)
except requests.exceptions.RequestException:
task.return_value((values, None))
return
game_cover = GdkPixbuf.Pixbuf.new_from_stream_at_scale(
tmp_file.read(), 2, 2, False
).scale_simple(*shared.image_size, GdkPixbuf.InterpType.BILINEAR)
itch_pixbuf = GdkPixbuf.Pixbuf.new_from_stream(tmp_file.read())
itch_pixbuf = itch_pixbuf.scale_simple(
shared.image_size[0],
itch_pixbuf.get_height() * (shared.image_size[0] / itch_pixbuf.get_width()),
GdkPixbuf.InterpType.BILINEAR,
)
itch_pixbuf.composite(
game_cover,
0,
(shared.image_size[1] - itch_pixbuf.get_height()) / 2,
itch_pixbuf.get_width(),
itch_pixbuf.get_height(),
0,
(shared.image_size[1] - itch_pixbuf.get_height()) / 2,
1.0,
1.0,
GdkPixbuf.InterpType.BILINEAR,
255,
)
else:
game_cover = None
task.return_value((values, game_cover))
def get_games_async(rows, importer):
current_time = int(time())
# Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args
def create_func(current_time, row):
def wrapper(task, *_args):
get_game(
task,
current_time,
row,
)
return wrapper
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[0],
resize_cover(pixbuf=final_values[1]),
)
for row in rows:
task = Gio.Task.new(None, None, update_games)
task.run_in_thread(create_func(current_time, row))
def itch_installed(path=None):
location_key = "itch-location"
check = Path("db") / "butler.db"
locations = (
(path,)
if path
else (
Path(shared.schema.get_string(location_key)).expanduser(),
Path.home() / ".var/app/io.itch.itch/config/itch",
shared.config_dir / "itch",
)
)
if os.name == "nt" and not path:
locations += (Path(os.getenv("appdata")) / "itch",)
itch_dir = check_install(check, locations, (shared.schema, location_key))
return itch_dir
def itch_importer():
itch_dir = itch_installed()
if not itch_dir:
return
database_path = (itch_dir / "db").expanduser()
db_cache_dir = shared.cache_dir / "cartridges" / "itch"
db_cache_dir.mkdir(parents=True, exist_ok=True)
# Copy the file because sqlite3 doesn't like databases in /run/user/
database_tmp_path = db_cache_dir / "butler.db"
for db_file in database_path.glob("butler.db*"):
copyfile(db_file, (db_cache_dir / db_file.name))
db_request = """
SELECT
games.id,
games.title,
games.cover_url,
games.still_cover_url,
caves.id
FROM
'caves'
INNER JOIN
'games'
ON
caves.game_id = games.id
;
"""
connection = connect(database_tmp_path)
cursor = connection.execute(db_request)
rows = cursor.fetchall()
connection.close()
# No need to unlink temp files as they disappear when the connection is closed
database_tmp_path.unlink(missing_ok=True)
importer = shared.importer
importer.total_queue += len(rows)
importer.queue += len(rows)
get_games_async(rows, importer)

View File

@@ -1,134 +0,0 @@
# lutris_importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from shutil import copyfile
from sqlite3 import connect
from time import time
from . import shared
from .check_install import check_install
def lutris_installed(path=None):
location_key = "lutris-location"
check = "pga.db"
locations = (
(path,)
if path
else (
Path(shared.schema.get_string(location_key)).expanduser(),
Path.home() / ".var/app/net.lutris.Lutris/data/lutris",
shared.data_dir / "lutris",
)
)
lutris_dir = check_install(check, locations, (shared.schema, location_key))
return lutris_dir
def lutris_cache_exists(path=None):
cache_key = "lutris-cache-location"
cache_check = "coverart"
cache_locations = (
(path,)
if path
else (
Path(shared.schema.get_string(cache_key)).expanduser(),
Path.home() / ".var" / "app" / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
)
)
cache_dir = check_install(cache_check, cache_locations, (shared.schema, cache_key))
return cache_dir
def lutris_importer():
lutris_dir = lutris_installed()
if not lutris_dir:
return
cache_dir = lutris_cache_exists()
if not cache_dir:
return
db_cache_dir = shared.cache_dir / "cartridges" / "lutris"
db_cache_dir.mkdir(parents=True, exist_ok=True)
# Copy the file because sqlite3 doesn't like databases in /run/user/
database_tmp_path = db_cache_dir / "pga.db"
for db_file in lutris_dir.glob("pga.db*"):
copyfile(db_file, (db_cache_dir / db_file.name))
db_request = """
SELECT
id, name, slug, runner, hidden
FROM
'games'
WHERE
name IS NOT NULL
AND slug IS NOT NULL
AND configPath IS NOT NULL
AND installed IS TRUE
;
"""
connection = connect(database_tmp_path)
cursor = connection.execute(db_request)
rows = cursor.fetchall()
connection.close()
# No need to unlink temp files as they disappear when the connection is closed
database_tmp_path.unlink(missing_ok=True)
if not shared.schema.get_boolean("lutris-import-steam"):
rows = [row for row in rows if not row[3] == "steam"]
current_time = int(time())
importer = shared.importer
importer.total_queue += len(rows)
importer.queue += len(rows)
for row in rows:
values = {}
values["game_id"] = f"lutris_{row[3]}_{row[0]}"
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
importer.save_game()
continue
values["added"] = current_time
values["executable"] = ["xdg-open", f"lutris:rungameid/{row[0]}"]
values["hidden"] = row[4] == 1
values["last_played"] = 0
values["name"] = row[1]
values["source"] = f"lutris_{row[3]}"
image_path = cache_dir / "coverart" / f"{row[2]}.jpg"
importer.save_game(values, image_path if image_path.is_file() else None)

View File

@@ -1,178 +0,0 @@
# steam_importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import re
from pathlib import Path
from time import time
import requests
from gi.repository import Gio
from . import shared
from .check_install import check_install
def update_values_from_data(content, values):
basic_data = content[values["appid"]]
if not basic_data["success"]:
values["blacklisted"] = True
else:
data = basic_data["data"]
if data.get("developers"):
values["developer"] = ", ".join(data["developers"])
if data.get("type") not in {"game", "demo"}:
values["blacklisted"] = True
return values
def get_game(task, datatypes, current_time, appmanifest, steam_dir):
values = {}
data = appmanifest.read_text("utf-8")
for datatype in datatypes:
value = re.findall(f'"{datatype}"\t\t"(.*)"\n', data, re.IGNORECASE)
try:
values[datatype] = value[0]
except IndexError:
task.return_value((None, None))
return
values["game_id"] = f'steam_{values["appid"]}'
if (
values["game_id"] in shared.win.games
and not shared.win.games[values["game_id"]].removed
):
task.return_value((None, None))
return
values["executable"] = (
["start", f'steam://rungameid/{values["appid"]}']
if os.name == "nt"
else ["xdg-open", f'steam://rungameid/{values["appid"]}']
)
values["hidden"] = False
values["source"] = "steam"
values["added"] = current_time
values["last_played"] = 0
image_path = (
steam_dir
/ "appcache"
/ "librarycache"
/ f'{values["appid"]}_library_600x900.jpg'
)
try:
with requests.get(
f'https://store.steampowered.com/api/appdetails?appids={values["appid"]}',
timeout=5,
) as open_file:
open_file.raise_for_status()
content = open_file.json()
except requests.exceptions.RequestException:
task.return_value((values, image_path if image_path.is_file() else None))
return
values = update_values_from_data(content, values)
task.return_value((values, image_path if image_path.is_file() else None))
def get_games_async(appmanifests, steam_dir, importer):
datatypes = ["appid", "name"]
current_time = int(time())
# Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args
def create_func(datatypes, current_time, appmanifest, steam_dir):
def wrapper(task, *_args):
get_game(
task,
datatypes,
current_time,
appmanifest,
steam_dir,
)
return wrapper
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[0], final_values[1])
for appmanifest in appmanifests:
task = Gio.Task.new(None, None, update_games)
task.run_in_thread(create_func(datatypes, current_time, appmanifest, steam_dir))
def steam_installed(path=None):
location_key = "steam-location"
check = "steamapps"
subdirs = ("steam", "Steam")
locations = (
(path,)
if path
else (
Path(shared.schema.get_string(location_key)).expanduser(),
Path.home() / ".steam",
shared.data_dir / "Steam",
Path.home() / ".var/app/com.valvesoftware.Steam/data/Steam",
)
)
if os.name == "nt":
locations += (Path(os.getenv("programfiles(x86)")) / "Steam",)
steam_dir = check_install(check, locations, (shared.schema, location_key), subdirs)
return steam_dir
def steam_importer():
steam_dir = steam_installed()
if not steam_dir:
return
appmanifests = []
if (lib_file := steam_dir / "steamapps" / "libraryfolders.vdf").is_file():
libraryfolders = lib_file.open().read()
steam_dirs = [
Path(path) for path in re.findall('"path"\t\t"(.*)"\n', libraryfolders)
]
else:
steam_dirs = [steam_dir]
for directory in steam_dirs:
try:
for open_file in (directory / "steamapps").iterdir():
if open_file.is_file() and "appmanifest" in open_file.name:
appmanifests.append(open_file)
except FileNotFoundError:
continue
importer = shared.importer
importer.total_queue += len(appmanifests)
importer.queue += len(appmanifests)
get_games_async(appmanifests, steam_dir, importer)

View File

@@ -0,0 +1,44 @@
# color_log_formatter.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from logging import Formatter, LogRecord
class ColorLogFormatter(Formatter):
"""Formatter that outputs logs in a colored format"""
RESET = "\033[0m"
DIM = "\033[2m"
BOLD = "\033[1m"
RED = "\033[31m"
YELLOW = "\033[33m"
def format(self, record: LogRecord):
super_format = super().format(record)
match record.levelname:
case "CRITICAL":
return self.BOLD + self.RED + super_format + self.RESET
case "ERROR":
return self.RED + super_format + self.RESET
case "WARNING":
return self.YELLOW + super_format + self.RESET
case "DEBUG":
return self.DIM + super_format + self.RESET
case _other:
return super_format

View File

@@ -0,0 +1,98 @@
# session_file_handler.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import lzma
from io import StringIO
from logging import StreamHandler
from lzma import FORMAT_XZ, PRESET_DEFAULT
from os import PathLike
from pathlib import Path
class SessionFileHandler(StreamHandler):
"""
A logging handler that writes to a new file on every app restart.
The files are compressed and older sessions logs are kept up to a small limit.
"""
backup_count: int
filename: Path
log_file: StringIO = None
def create_dir(self) -> None:
"""Create the log dir if needed"""
self.filename.parent.mkdir(exist_ok=True, parents=True)
def rotate_file(self, file: Path):
"""Rotate a file's number suffix and remove it if it's too old"""
# Skip non interesting dir entries
if not (file.is_file() and file.name.startswith(self.filename.name)):
return
# Compute the new number suffix
suffixes = file.suffixes
has_number = len(suffixes) != len(self.filename.suffixes)
current_number = 0 if not has_number else int(suffixes[-1][1:])
new_number = current_number + 1
# Rename with new number suffix
if has_number:
suffixes.pop()
suffixes.append(f".{new_number}")
stem = file.name.split(".", maxsplit=1)[0]
new_name = stem + "".join(suffixes)
file = file.rename(file.with_name(new_name))
# Remove older files
if new_number > self.backup_count:
file.unlink()
return
def file_sort_key(self, file: Path) -> int:
"""Key function used to sort files"""
if not file.name.startswith(self.filename.name):
# First all files that aren't logs
return -1
if file.name == self.filename.name:
# Then the latest log file
return 0
# Then in order the other log files
return int(file.suffixes[-1][1:])
def rotate(self) -> None:
"""Rotate the numbered suffix on the log files and remove old ones"""
files = list(self.filename.parent.iterdir())
files.sort(key=self.file_sort_key, reverse=True)
for file in files:
self.rotate_file(file)
def __init__(self, filename: PathLike, backup_count: int = 2) -> None:
self.filename = Path(filename)
self.backup_count = backup_count
self.create_dir()
self.rotate()
self.log_file = lzma.open(
self.filename, "at", format=FORMAT_XZ, preset=PRESET_DEFAULT
)
super().__init__(self.log_file)
def close(self) -> None:
self.log_file.close()
super().close()

111
src/logging/setup.py Normal file
View File

@@ -0,0 +1,111 @@
# setup.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
import logging.config as logging_dot_config
import os
import subprocess
import sys
from src import shared
def setup_logging():
"""Intitate the app's logging"""
is_dev = shared.PROFILE == "development"
profile_app_log_level = "DEBUG" if is_dev else "INFO"
profile_lib_log_level = "INFO" if is_dev else "WARNING"
app_log_level = os.environ.get("LOGLEVEL", profile_app_log_level).upper()
lib_log_level = os.environ.get("LIBLOGLEVEL", profile_lib_log_level).upper()
log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log.xz"
config = {
"version": 1,
"formatters": {
"file_formatter": {
"format": "%(asctime)s | %(name)s | %(levelname)s | %(message)s"
},
"console_formatter": {
"format": "%(name)s %(levelname)s - %(message)s",
"class": "src.logging.color_log_formatter.ColorLogFormatter",
},
},
"handlers": {
"file_handler": {
"class": "src.logging.session_file_handler.SessionFileHandler",
"formatter": "file_formatter",
"level": "DEBUG",
"filename": log_filename,
"backup_count": 3,
},
"app_console_handler": {
"class": "logging.StreamHandler",
"formatter": "console_formatter",
"level": app_log_level,
},
"lib_console_handler": {
"class": "logging.StreamHandler",
"formatter": "console_formatter",
"level": lib_log_level,
},
},
"loggers": {
"PIL": {
"handlers": ["lib_console_handler", "file_handler"],
"propagate": False,
"level": "NOTSET",
},
"urllib3": {
"handlers": ["lib_console_handler", "file_handler"],
"propagate": False,
"level": "NOTSET",
},
},
"root": {
"level": "NOTSET",
"handlers": ["app_console_handler", "file_handler"],
},
}
logging_dot_config.dictConfig(config)
def log_system_info():
"""Log system debug information"""
logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE)
logging.debug("System: %s", sys.platform)
logging.debug("Python version: %s", sys.version)
if os.getenv("FLATPAK_ID"):
process = subprocess.run(
("flatpak-spawn", "--host", "flatpak", "--version"),
capture_output=True,
encoding="utf-8",
check=False,
)
logging.debug("Flatpak version: %s", process.stdout.rstrip())
if os.name == "posix":
uname = os.uname()
logging.debug("Uname info:")
logging.debug("\tsysname: %s", uname.sysname)
logging.debug("\trelease: %s", uname.release)
logging.debug("\tversion: %s", uname.version)
logging.debug("\tmachine: %s", uname.machine)
logging.debug("-" * 80)

View File

@@ -16,7 +16,7 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import sys
import gi
@@ -27,16 +27,26 @@ gi.require_version("Adw", "1")
# pylint: disable=wrong-import-position
from gi.repository import Adw, Gio, GLib, Gtk
from . import shared
from .bottles_importer import bottles_importer
from .details_window import DetailsWindow
from .heroic_importer import heroic_importer
from .importer import Importer
from .itch_importer import itch_importer
from .lutris_importer import lutris_importer
from .preferences import PreferencesWindow
from .steam_importer import steam_importer
from .window import CartridgesWindow
from src import shared
from src.details_window import DetailsWindow
from src.game import Game
from src.importer.importer import Importer
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.steam_source import SteamSource
from src.logging.setup import log_system_info, setup_logging
from src.preferences import PreferencesWindow
from src.store.managers.display_manager import DisplayManager
from src.store.managers.file_manager import FileManager
from src.store.managers.local_cover_manager import LocalCoverManager
from src.store.managers.online_cover_manager import OnlineCoverManager
from src.store.managers.sgdb_manager import SGDBManager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.store.store import Store
from src.window import CartridgesWindow
class CartridgesApplication(Adw.Application):
@@ -48,13 +58,15 @@ class CartridgesApplication(Adw.Application):
)
def do_activate(self): # pylint: disable=arguments-differ
"""Called on app creation"""
# Set fallback icon-name
Gtk.Window.set_default_icon_name(shared.APP_ID)
# Create the main window
self.win = self.props.active_window # pylint: disable=no-member
if not self.win:
self.win = CartridgesWindow(application=self)
shared.win = self.win = CartridgesWindow(application=self)
# Save window geometry
shared.state_schema.bind(
@@ -67,6 +79,21 @@ class CartridgesApplication(Adw.Application):
"is-maximized", self.win, "maximized", Gio.SettingsBindFlags.DEFAULT
)
# Create the games store ready to load games from disk
if not shared.store:
shared.store = Store()
shared.store.add_manager(FileManager(), False)
shared.store.add_manager(DisplayManager())
self.load_games_from_disk()
# Add rest of the managers for game imports
shared.store.add_manager(LocalCoverManager())
shared.store.add_manager(SteamAPIManager())
shared.store.add_manager(OnlineCoverManager())
shared.store.add_manager(SGDBManager())
shared.store.enable_manager_in_pipelines(FileManager)
# Create actions
self.create_actions(
{
@@ -106,6 +133,13 @@ class CartridgesApplication(Adw.Application):
self.win.present()
def load_games_from_disk(self):
if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir():
data = json.load(game_file.open())
game = Game(data, allow_side_effects=False)
shared.store.add_game(game, {"skip_save": True})
def on_about_action(self, *_args):
about = Adw.AboutWindow(
transient_for=self.win,
@@ -115,9 +149,9 @@ class CartridgesApplication(Adw.Application):
version=shared.VERSION,
developers=[
"kramo https://kramo.hu",
"Geoffrey Coulaud https://geoffrey-coulaud.fr",
"Arcitec https://github.com/Arcitec",
"Domenico https://github.com/Domefemia",
"Geoffrey Coulaud https://geoffrey-coulaud.fr",
"Paweł Lidwin https://github.com/imLinguin",
"Rafael Mardojai CM https://mardojai.com",
],
@@ -141,6 +175,8 @@ class CartridgesApplication(Adw.Application):
getattr(win, expander_row).set_expanded(True)
win.present()
return win
def on_launch_game_action(self, *_args):
self.win.active_game.launch()
@@ -154,30 +190,27 @@ class CartridgesApplication(Adw.Application):
DetailsWindow()
def on_import_action(self, *_args):
shared.importer = Importer()
shared.importer.blocker = True
if shared.schema.get_boolean("steam"):
steam_importer()
importer = Importer()
if shared.schema.get_boolean("lutris"):
lutris_importer()
importer.add_source(LutrisSource())
if shared.schema.get_boolean("steam"):
importer.add_source(SteamSource())
if shared.schema.get_boolean("heroic"):
heroic_importer()
importer.add_source(HeroicSource())
if shared.schema.get_boolean("bottles"):
bottles_importer()
importer.add_source(BottlesSource())
if shared.schema.get_boolean("itch"):
itch_importer()
importer.add_source(ItchSource())
shared.importer.blocker = False
if shared.schema.get_boolean("legendary"):
importer.add_source(LegendarySource())
if shared.importer.import_dialog.is_visible and shared.importer.queue == 0:
shared.importer.queue = 1
shared.importer.save_game()
importer.run()
def on_remove_game_action(self, *_args):
self.win.active_game.remove_game()
@@ -223,6 +256,9 @@ class CartridgesApplication(Adw.Application):
scope.add_action(simple_action)
def main(version): # pylint: disable=unused-argument
def main(_version):
"""App entry point"""
setup_logging()
log_system_info()
app = CartridgesApplication()
return app.run(sys.argv)

View File

@@ -1,4 +1,4 @@
moduledir = join_paths(pkgdatadir, 'cartridges')
moduledir = join_paths(pkgdatadir, 'src')
configure_file(
input: 'cartridges.in',
@@ -8,29 +8,24 @@ configure_file(
install_dir: get_option('bindir')
)
cartridges_sources = [
configure_file(
input: 'shared.py.in',
output: 'shared.py',
configuration: conf
),
'__init__.py',
'main.py',
'window.py',
'preferences.py',
'details_window.py',
'game.py',
'game_cover.py',
'importers/steam_importer.py',
'importers/lutris_importer.py',
'importers/heroic_importer.py',
'importers/bottles_importer.py',
'importers/itch_importer.py',
'utils/importer.py',
'utils/steamgriddb.py',
'utils/save_cover.py',
'utils/create_dialog.py',
'utils/check_install.py'
]
install_data(cartridges_sources, install_dir: moduledir)
install_subdir('importer', install_dir: moduledir)
install_subdir('utils', install_dir: moduledir)
install_subdir('store', install_dir: moduledir)
install_subdir('logging', install_dir: moduledir)
install_subdir('errors', install_dir: moduledir)
install_data(
[
'main.py',
'window.py',
'preferences.py',
'details_window.py',
'game.py',
'game_cover.py',
configure_file(
input: 'shared.py.in',
output: 'shared.py',
configuration: conf
)
],
install_dir: moduledir
)

View File

@@ -17,20 +17,22 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import os
import logging
import re
from pathlib import Path
from shutil import rmtree
from gi.repository import Adw, Gio, GLib, Gtk
# pylint: disable=unused-import
from . import shared
from .bottles_importer import bottles_installed
from .create_dialog import create_dialog
from .heroic_importer import heroic_installed
from .itch_importer import itch_installed
from .lutris_importer import lutris_cache_exists, lutris_installed
from .steam_importer import steam_installed
from src import shared
from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.source import Source
from src.importer.sources.steam_source import SteamSource
from src.utils.create_dialog import create_dialog
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/preferences.ui")
@@ -46,33 +48,36 @@ class PreferencesWindow(Adw.PreferencesWindow):
exit_after_launch_switch = Gtk.Template.Child()
cover_launches_game_switch = Gtk.Template.Child()
high_quality_images_switch = Gtk.Template.Child()
remove_all_games_button = Gtk.Template.Child()
steam_expander_row = Gtk.Template.Child()
steam_action_row = Gtk.Template.Child()
steam_file_chooser_button = Gtk.Template.Child()
steam_data_action_row = Gtk.Template.Child()
steam_data_file_chooser_button = Gtk.Template.Child()
lutris_expander_row = Gtk.Template.Child()
lutris_action_row = Gtk.Template.Child()
lutris_file_chooser_button = Gtk.Template.Child()
lutris_data_action_row = Gtk.Template.Child()
lutris_data_file_chooser_button = Gtk.Template.Child()
lutris_cache_action_row = Gtk.Template.Child()
lutris_cache_file_chooser_button = Gtk.Template.Child()
lutris_import_steam_switch = Gtk.Template.Child()
heroic_expander_row = Gtk.Template.Child()
heroic_action_row = Gtk.Template.Child()
heroic_file_chooser_button = Gtk.Template.Child()
heroic_config_action_row = Gtk.Template.Child()
heroic_config_file_chooser_button = Gtk.Template.Child()
heroic_import_epic_switch = Gtk.Template.Child()
heroic_import_gog_switch = Gtk.Template.Child()
heroic_import_sideload_switch = Gtk.Template.Child()
bottles_expander_row = Gtk.Template.Child()
bottles_action_row = Gtk.Template.Child()
bottles_file_chooser_button = Gtk.Template.Child()
bottles_data_action_row = Gtk.Template.Child()
bottles_data_file_chooser_button = Gtk.Template.Child()
itch_expander_row = Gtk.Template.Child()
itch_action_row = Gtk.Template.Child()
itch_file_chooser_button = Gtk.Template.Child()
itch_config_action_row = Gtk.Template.Child()
itch_config_file_chooser_button = Gtk.Template.Child()
legendary_expander_row = Gtk.Template.Child()
legendary_config_action_row = Gtk.Template.Child()
legendary_config_file_chooser_button = Gtk.Template.Child()
sgdb_key_group = Gtk.Template.Child()
sgdb_key_entry_row = Gtk.Template.Child()
@@ -81,12 +86,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
sgdb_prefer_switch = Gtk.Template.Child()
sgdb_animated_switch = Gtk.Template.Child()
removed_games = set()
danger_zone_group = Gtk.Template.Child()
reset_action_row = Gtk.Template.Child()
reset_button = Gtk.Template.Child()
remove_all_games_button = Gtk.Template.Child()
# Whether to import after closing the window
import_changed = False
# Widgets and their properties to check whether to import after closing the window
import_changed_widgets = {}
removed_games = set()
def __init__(self, **kwargs):
super().__init__(**kwargs)
@@ -110,49 +115,27 @@ class PreferencesWindow(Adw.PreferencesWindow):
# General
self.remove_all_games_button.connect("clicked", self.remove_all_games)
# Steam
self.create_preferences(self, "steam", "Steam")
# Lutris
self.create_preferences(self, "lutris", "Lutris")
def set_cache_dir(_source, result, *_args):
try:
path = Path(self.file_chooser.select_folder_finish(result).get_path())
except GLib.GError:
return
def response(widget, response):
if response == "choose_folder":
self.choose_folder(widget, set_cache_dir)
if lutris_cache_exists(path):
self.import_changed = True
self.set_subtitle(self, "lutris-cache")
# Debug
if shared.PROFILE == "development":
self.reset_action_row.set_visible(True)
self.reset_button.connect("clicked", self.reset_app)
self.set_default_size(-1, 560)
# Sources settings
for source_class in (
BottlesSource,
HeroicSource,
ItchSource,
LegendarySource,
LutrisSource,
SteamSource,
):
source = source_class()
if not source.is_available:
expander_row = getattr(self, f"{source.id}_expander_row")
expander_row.remove()
else:
create_dialog(
self.win,
_("Cache Not Found"),
_("Select the Lutris cache directory."),
"choose_folder",
_("Set Location"),
).connect("response", response)
self.set_subtitle(self, "lutris-cache")
self.lutris_cache_file_chooser_button.connect(
"clicked", self.choose_folder, set_cache_dir
)
# Heroic
self.create_preferences(self, "heroic", "Heroic", True)
# Bottles
self.create_preferences(self, "bottles", "Bottles")
# itch
self.create_preferences(self, "itch", "itch", True)
self.init_source_row(source)
# SteamGridDB
def sgdb_key_changed(*_args):
@@ -194,25 +177,6 @@ class PreferencesWindow(Adw.PreferencesWindow):
)
)
# Connect the switches that change the behavior of importing to set_import_changed
self.connect_import_switches(
(
"lutris-import-steam",
"heroic-import-epic",
"heroic-import-gog",
"heroic-import-sideload",
)
)
# Windows
if os.name == "nt":
self.sources_group.remove(self.lutris_expander_row)
self.sources_group.remove(self.bottles_expander_row)
# When the user interacts with a widget that changes the behavior of importing,
# Cartridges should automatically import upon closing the preferences window
self.connect("close-request", self.check_import)
def get_switch(self, setting):
return getattr(self, f'{setting.replace("-", "_")}_switch')
@@ -225,17 +189,14 @@ class PreferencesWindow(Adw.PreferencesWindow):
Gio.SettingsBindFlags.DEFAULT,
)
def connect_import_switches(self, settings):
for setting in settings:
self.get_switch(setting).connect("notify::active", self.set_import_changed)
def choose_folder(self, _widget, function):
self.file_chooser.select_folder(self.win, None, function, None)
def choose_folder(self, _widget, callback, callback_data=None):
self.file_chooser.select_folder(self.win, None, callback, callback_data)
def undo_remove_all(self, *_args):
for game in self.removed_games:
game.removed = False
game.save()
game.update()
self.removed_games = set()
self.toast.dismiss()
@@ -247,85 +208,107 @@ class PreferencesWindow(Adw.PreferencesWindow):
game.removed = True
game.save()
game.update()
if self.win.stack.get_visible_child() == self.win.details_view:
self.win.on_go_back_action()
self.add_toast(self.toast)
def set_subtitle(self, win, source_id):
getattr(win, f'{source_id.replace("-", "_")}_action_row').set_subtitle(
# Remove the path if the dir is picked via the Flatpak portal
re.sub(
"/run/user/\\d*/doc/.*/",
"",
str(
Path(shared.schema.get_string(f"{source_id}-location")).expanduser()
),
)
)
def reset_app(*_args):
rmtree(shared.data_dir / "cartridges", True)
rmtree(shared.config_dir / "cartridges", True)
rmtree(shared.cache_dir / "cartridges", True)
for key in (
(settings_schema_source := Gio.SettingsSchemaSource.get_default())
.lookup(shared.APP_ID, True)
.list_keys()
):
shared.schema.reset(key)
for key in settings_schema_source.lookup(
shared.APP_ID + ".State", True
).list_keys():
shared.state_schema.reset(key)
shared.win.get_application().quit()
def update_source_action_row_paths(self, source):
"""Set the dir subtitle for a source's action rows"""
for location in ("data", "config", "cache"):
# Get the action row to subtitle
action_row = getattr(self, f"{source.id}_{location}_action_row", None)
if not action_row:
continue
# Historically "location" meant data or config, so the key stays shared
infix = "-cache" if location == "cache" else ""
key = f"{source.id}{infix}-location"
path = Path(shared.schema.get_string(key)).expanduser()
# Remove the path if the dir is picked via the Flatpak portal
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle)
def init_source_row(self, source: Source):
"""Initialize a preference row for a source class"""
def set_dir(_widget, result, location_name):
"""Callback called when a dir picker button is clicked"""
def create_preferences(self, win, source_id, name, config=False):
def set_dir(_source, result, *_args):
try:
path = Path(win.file_chooser.select_folder_finish(result).get_path())
path = Path(self.file_chooser.select_folder_finish(result).get_path())
except GLib.GError:
return
def response(widget, response):
if response == "choose_folder":
win.choose_folder(widget, set_dir)
if globals()[f"{source_id}_installed"](path):
self.import_changed = True
self.set_subtitle(win, source_id)
# Good picked location
location = getattr(source, f"{location_name}_location")
if location.check_candidate(path):
# Set the schema
infix = "-cache" if location_name == "cache" else ""
key = f"{source.id}{infix}-location"
value = str(path)
shared.schema.set_string(key, value)
# Update the row
self.update_source_action_row_paths(source)
logging.debug("User-set value for schema key %s: %s", key, value)
# Bad picked location, inform user
else:
create_dialog(
win,
_("Installation Not Found"),
# The variable is the name of the game launcher
_("Select the {} configuration directory.").format(name) if config
# The variable is the name of the game launcher
else _("Select the {} data directory.").format(name),
if location_name == "cache":
title = "Cache directory not found"
subtitle_format = "Select the {} cache directory."
else:
title = "Installation directory not found"
subtitle_format = "Select the {} installation directory."
dialog = create_dialog(
self,
_(title),
_(subtitle_format).format(source.name),
"choose_folder",
_("Set Location"),
).connect("response", response)
)
self.set_subtitle(win, source_id)
def on_response(widget, response):
if response == "choose_folder":
self.choose_folder(widget, set_dir, location_name)
dialog.connect("response", on_response)
# Bind expander row activation to source being enabled
expander_row = getattr(self, f"{source.id}_expander_row")
shared.schema.bind(
source_id,
getattr(win, f"{source_id}_expander_row"),
source.id,
expander_row,
"enable-expansion",
Gio.SettingsBindFlags.DEFAULT,
)
getattr(win, f"{source_id}_file_chooser_button").connect(
"clicked", win.choose_folder, set_dir
)
# Connect dir picker buttons
for location in ("data", "config", "cache"):
button = getattr(self, f"{source.id}_{location}_file_chooser_button", None)
if button is not None:
button.connect("clicked", self.choose_folder, set_dir, location)
getattr(win, f"{source_id}_expander_row").connect(
"notify::enable-expansion", self.set_import_changed
)
def set_import_changed(self, widget, param):
if widget not in self.import_changed_widgets:
self.import_changed = True
self.import_changed_widgets[widget] = (
param.name,
not widget.get_property(param.name),
)
def check_import(self, *_args):
# This checks whether any of the switches that did actually change their state
# would have an effect on the outcome of the import action
# and if they would, it initiates it.
if self.import_changed and any(
(value := widget.get_property(prop[0])) and value != prop[1]
for widget, prop in self.import_changed_widgets.items()
):
# The timeout is a hack to circumvent a GTK bug that I'm too lazy to report:
# The window would stay darkened because of the import dialog for some reason
GLib.timeout_add(1, self.win.get_application().on_import_action)
# Set the source row subtitles
self.update_source_action_row_paths(source)

View File

@@ -25,6 +25,7 @@ from gi.repository import Gdk, Gio
APP_ID = "@APP_ID@"
VERSION = "@VERSION@"
PREFIX = "@PREFIX@"
PROFILE = "@PROFILE@"
SPEC_VERSION = 1.5 # The version of the game_id.json spec
schema = Gio.Settings.new(APP_ID)
@@ -49,6 +50,9 @@ cache_dir = (
games_dir = data_dir / "cartridges" / "games"
covers_dir = data_dir / "cartridges" / "covers"
appdata_dir = Path(os.getenv("appdata") or "C:\\Users\\Default\\AppData\\Roaming")
programfiles32_dir = Path(os.getenv("programfiles(x86)") or "C:\\Program Files (x86)")
scale_factor = max(
monitor.get_scale_factor() for monitor in Gdk.Display.get_default().get_monitors()
)
@@ -57,3 +61,4 @@ image_size = (200 * scale_factor, 300 * scale_factor)
# pylint: disable=invalid-name
win = None
importer = None
store = None

View File

@@ -0,0 +1,64 @@
# async_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Callable, Any
from gi.repository import Gio
from src.game import Game
from src.store.managers.manager import Manager
from src.utils.task import Task
class AsyncManager(Manager):
"""Manager that can run asynchronously"""
blocking = False
cancellable: Gio.Cancellable = None
def __init__(self) -> None:
super().__init__()
self.cancellable = Gio.Cancellable()
def cancel_tasks(self):
"""Cancel all tasks for this manager"""
self.cancellable.cancel()
def reset_cancellable(self):
"""Reset the cancellable for this manager.
Already scheduled Tasks will no longer be cancellable."""
self.cancellable = Gio.Cancellable()
def process_game(
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None:
"""Create a task to process the game in a separate thread"""
task = Task.new(None, self.cancellable, self._task_callback, (callback,))
task.set_task_data((game, additional_data))
task.run_in_thread(self._task_thread_func)
def _task_thread_func(self, _task, _source_object, data, _cancellable):
"""Task thread entry point"""
game, additional_data, *_rest = data
self.execute_resilient_manager_logic(game, additional_data)
def _task_callback(self, _source_object, _result, data):
"""Method run after the task is done"""
callback, *_rest = data
callback(self)

View File

@@ -0,0 +1,74 @@
# display_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from src import shared
from src.game import Game
from src.game_cover import GameCover
from src.store.managers.manager import Manager
from src.store.managers.sgdb_manager import SGDBManager
from src.store.managers.steam_api_manager import SteamAPIManager
class DisplayManager(Manager):
"""Manager in charge of adding a game to the UI"""
run_after = (SteamAPIManager, SGDBManager)
signals = {"update-ready"}
def manager_logic(self, game: Game, _additional_data: dict) -> None:
shared.win.games[game.game_id] = game
if game.get_parent():
game.get_parent().get_parent().remove(game)
if game.get_parent():
game.get_parent().set_child()
game.menu_button.set_menu_model(
game.hidden_game_options if game.hidden else game.game_options
)
game.title.set_label(game.name)
game.menu_button.get_popover().connect(
"notify::visible", game.toggle_play, None
)
game.menu_button.get_popover().connect(
"notify::visible", game.win.set_active_game, game
)
if game.game_id in game.win.game_covers:
game.game_cover = game.win.game_covers[game.game_id]
game.game_cover.add_picture(game.cover)
else:
game.game_cover = GameCover({game.cover}, game.get_cover_path())
game.win.game_covers[game.game_id] = game.game_cover
if (
game.win.stack.get_visible_child() == game.win.details_view
and game.win.active_game == game
):
game.win.show_details_view(game)
if not game.removed and not game.blacklisted:
if game.hidden:
game.win.hidden_library.append(game)
else:
game.win.library.append(game)
game.get_parent().set_focusable(False)
game.win.set_library_child()

View File

@@ -0,0 +1,59 @@
# file_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from src import shared
from src.game import Game
from src.store.managers.async_manager import AsyncManager
from src.store.managers.steam_api_manager import SteamAPIManager
class FileManager(AsyncManager):
"""Manager in charge of saving a game to a file"""
run_after = (SteamAPIManager,)
signals = {"save-ready"}
def manager_logic(self, game: Game, additional_data: dict) -> None:
if additional_data.get("skip_save"): # Skip saving when loading games from disk
return
shared.games_dir.mkdir(parents=True, exist_ok=True)
attrs = (
"added",
"executable",
"game_id",
"source",
"hidden",
"last_played",
"name",
"developer",
"removed",
"blacklisted",
"version",
)
json.dump(
{attr: getattr(game, attr) for attr in attrs if attr},
(shared.games_dir / f"{game.game_id}.json").open("w"),
indent=4,
sort_keys=True,
)

View File

@@ -0,0 +1,42 @@
# local_cover_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from src.game import Game
from src.store.managers.manager import Manager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.save_cover import save_cover, resize_cover
class LocalCoverManager(Manager):
"""Manager in charge of adding the local cover image of the game"""
run_after = (SteamAPIManager,)
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that the cover path is in the additional data
try:
image_path: Path = additional_data["local_image_path"]
except KeyError:
return
if not image_path.is_file():
return
# Save the image
save_cover(game.game_id, resize_cover(image_path))

View File

@@ -0,0 +1,120 @@
# manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from abc import abstractmethod
from time import sleep
from typing import Any, Callable, Container
from src.errors.error_producer import ErrorProducer
from src.errors.friendly_error import FriendlyError
from src.game import Game
class Manager(ErrorProducer):
"""Class in charge of handling a post creation action for games.
* May connect to signals on the game to handle them.
* May cancel its running tasks on critical error,
in that case a new cancellable must be generated for new tasks to run.
* May be retried on some specific error types
"""
run_after: Container[type["Manager"]] = tuple()
blocking: bool = True
retryable_on: Container[type[Exception]] = tuple()
continue_on: Container[type[Exception]] = tuple()
signals: Container[type[str]] = set()
retry_delay: int = 3
max_tries: int = 3
@property
def name(self):
return type(self).__name__
@abstractmethod
def manager_logic(self, game: Game, additional_data: dict) -> None:
"""
Manager specific logic triggered by the run method
* Implemented by final child classes
* May block its thread
* May raise retryable exceptions that will trigger a retry if possible
* May raise other exceptions that will be reported
"""
def execute_resilient_manager_logic(self, game: Game, additional_data: dict):
"""Handle errors (retry, ignore or raise) that occur the manager logic"""
# Keep track of the number of tries
tries = 1
def handle_error(error: Exception):
nonlocal tries
# If FriendlyError, handle its cause instead
base_error = error
if isinstance(error, FriendlyError):
error = error.__cause__
log_args = (
type(error).__name__,
self.name,
f"{game.name} ({game.game_id})",
)
out_of_retries_format = "Out of retries dues to %s in %s for %s"
retrying_format = "Retrying %s in %s for %s"
unretryable_format = "Unretryable %s in %s for %s"
if error in self.continue_on:
# Handle skippable errors (skip silently)
return
if error in self.retryable_on:
if tries > self.max_tries:
# Handle being out of retries
logging.error(out_of_retries_format, *log_args)
self.report_error(base_error)
else:
# Handle retryable errors
logging.error(retrying_format, *log_args)
sleep(self.retry_delay)
tries += 1
try_manager_logic()
else:
# Handle unretryable errors
logging.error(unretryable_format, *log_args, exc_info=error)
self.report_error(base_error)
def try_manager_logic():
try:
self.manager_logic(game, additional_data)
except Exception as error: # pylint: disable=broad-exception-caught
handle_error(error)
try_manager_logic()
def process_game(
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None:
"""Pass the game through the manager"""
self.execute_resilient_manager_logic(game, additional_data)
callback(self)

View File

@@ -0,0 +1,126 @@
# online_cover_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from pathlib import Path
import requests
from gi.repository import Gio, GdkPixbuf
from requests.exceptions import HTTPError, SSLError
from PIL import Image
from src import shared
from src.game import Game
from src.store.managers.local_cover_manager import LocalCoverManager
from src.store.managers.manager import Manager
from src.utils.save_cover import resize_cover, save_cover
class OnlineCoverManager(Manager):
"""Manager that downloads game covers from URLs"""
run_after = (LocalCoverManager,)
retryable_on = (HTTPError, SSLError)
def save_composited_cover(
self,
game: Game,
image_file: Gio.File,
original_width: int,
original_height: int,
target_width: int,
target_height: int,
) -> None:
"""Save the image composited with a background blur to fit the cover size"""
logging.debug(
"Compositing image for %s (%s) %dx%d -> %dx%d",
game.name,
game.game_id,
original_width,
original_height,
target_width,
target_height,
)
# Load game image
image = GdkPixbuf.Pixbuf.new_from_stream(image_file.read())
# Create background blur of the size of the cover
cover = image.scale_simple(2, 2, GdkPixbuf.InterpType.BILINEAR).scale_simple(
target_width, target_height, GdkPixbuf.InterpType.BILINEAR
)
# Center the image above the blurred background
scale = min(target_width / original_width, target_height / original_height)
left_padding = (target_width - original_width * scale) / 2
top_padding = (target_height - original_height * scale) / 2
image.composite(
cover,
# Top left of overwritten area on the destination
left_padding,
top_padding,
# Size of the overwritten area on the destination
original_width * scale,
original_height * scale,
# Offset
left_padding,
top_padding,
# Scale to apply to the resized image
scale,
scale,
# Compositing stuff
GdkPixbuf.InterpType.BILINEAR,
255,
)
# Resize and save the cover
save_cover(game.game_id, resize_cover(pixbuf=cover))
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that we have a cover to download
cover_url = additional_data.get("online_cover_url")
if not cover_url:
return
# Download cover
image_file = Gio.File.new_tmp()[0]
image_path = Path(image_file.get_path())
with requests.get(cover_url, timeout=5) as cover:
cover.raise_for_status()
image_path.write_bytes(cover.content)
# Get image size
cover_width, cover_height = shared.image_size
with Image.open(image_path) as pil_image:
width, height = pil_image.size
# Composite if the image is shorter and the stretch amount is too high
aspect_ratio = width / height
target_aspect_ratio = cover_width / cover_height
is_taller = aspect_ratio < target_aspect_ratio
resized_height = height / width * cover_width
stretch = 1 - (resized_height / cover_height)
max_stretch = 0.12
if is_taller or stretch <= max_stretch:
save_cover(game.game_id, resize_cover(image_path))
else:
self.save_composited_cover(
game, image_file, width, height, cover_width, cover_height
)

View File

@@ -0,0 +1,49 @@
# sgdb_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from json import JSONDecodeError
from requests.exceptions import HTTPError, SSLError
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.store.managers.async_manager import AsyncManager
from src.store.managers.local_cover_manager import LocalCoverManager
from src.store.managers.online_cover_manager import OnlineCoverManager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.steamgriddb import SGDBAuthError, SGDBHelper
class SGDBManager(AsyncManager):
"""Manager in charge of downloading a game's cover from steamgriddb"""
run_after = (SteamAPIManager, LocalCoverManager, OnlineCoverManager)
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
def manager_logic(self, game: Game, _additional_data: dict) -> None:
try:
sgdb = SGDBHelper()
sgdb.conditionaly_update_cover(game)
except SGDBAuthError as error:
# If invalid auth, cancel all SGDBManager tasks
self.cancellable.cancel()
raise FriendlyError(
_("Couldn't Authenticate SteamGridDB"),
_("Verify your API key in preferences"),
) from error

View File

@@ -0,0 +1,56 @@
# steam_api_manager.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from requests.exceptions import HTTPError, SSLError
from src.game import Game
from src.store.managers.async_manager import AsyncManager
from src.utils.steam import (
SteamGameNotFoundError,
SteamAPIHelper,
SteamNotAGameError,
SteamRateLimiter,
)
class SteamAPIManager(AsyncManager):
"""Manager in charge of completing a game's data from the Steam API"""
retryable_on = (HTTPError, SSLError)
steam_api_helper: SteamAPIHelper = None
steam_rate_limiter: SteamRateLimiter = None
def __init__(self) -> None:
super().__init__()
self.steam_rate_limiter = SteamRateLimiter()
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Skip non-steam games
appid = additional_data.get("steam_appid", None)
if appid is None:
return
# Get online metadata
try:
online_data = self.steam_api_helper.get_api_data(appid=appid)
except (SteamNotAGameError, SteamGameNotFoundError):
game.update_values({"blacklisted": True})
else:
game.update_values(online_data)

110
src/store/pipeline.py Normal file
View File

@@ -0,0 +1,110 @@
# pipeline.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from typing import Iterable
from gi.repository import GObject
from src.game import Game
from src.store.managers.manager import Manager
class Pipeline(GObject.Object):
"""Class representing a set of managers for a game"""
game: Game
additional_data: dict
waiting: set[Manager]
running: set[Manager]
done: set[Manager]
def __init__(
self, game: Game, additional_data: dict, managers: Iterable[Manager]
) -> None:
super().__init__()
self.game = game
self.additional_data = additional_data
self.waiting = set(managers)
self.running = set()
self.done = set()
@property
def not_done(self) -> set[Manager]:
"""Get the managers that are not done yet"""
return self.waiting | self.running
@property
def is_done(self) -> bool:
return len(self.waiting) == 0 and len(self.running) == 0
@property
def blocked(self) -> set[Manager]:
"""Get the managers that cannot run because their dependencies aren't done"""
blocked = set()
for waiting in self.waiting:
for not_done in self.not_done:
if waiting == not_done:
continue
if type(not_done) in waiting.run_after:
blocked.add(waiting)
return blocked
@property
def ready(self) -> set[Manager]:
"""Get the managers that can be run"""
return self.waiting - self.blocked
@property
def progress(self) -> float:
"""Get the pipeline progress. Should only be a rough idea."""
n_done = len(self.done)
n_total = len(self.waiting) + len(self.running) + n_done
try:
progress = n_done / n_total
except ZeroDivisionError:
progress = 1
return progress
def advance(self):
"""Spawn tasks for managers that are able to run for a game"""
# Separate blocking / async managers
managers = self.ready
blocking = set(filter(lambda manager: manager.blocking, managers))
parallel = managers - blocking
# Schedule parallel managers, then run the blocking ones
for manager in (*parallel, *blocking):
self.waiting.remove(manager)
self.running.add(manager)
manager.process_game(self.game, self.additional_data, self.manager_callback)
def manager_callback(self, manager: Manager) -> None:
"""Method called by a manager when it's done"""
logging.debug("%s done for %s", manager.name, self.game.game_id)
self.running.remove(manager)
self.done.add(manager)
self.emit("advanced")
self.advance()
@GObject.Signal(name="advanced")
def advanced(self) -> None:
"""Signal emitted when the pipeline has advanced"""

106
src/store/store.py Normal file
View File

@@ -0,0 +1,106 @@
# store.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from src import shared
from src.game import Game
from src.store.managers.manager import Manager
from src.store.pipeline import Pipeline
class Store:
"""Class in charge of handling games being added to the app."""
managers: dict[type[Manager], Manager]
pipeline_managers: set[Manager]
pipelines: dict[str, Pipeline]
games: dict[str, Game]
def __init__(self) -> None:
self.managers = {}
self.pipeline_managers = set()
self.pipelines = {}
self.games = {}
def add_manager(self, manager: Manager, in_pipeline=True):
"""Add a manager to the store"""
manager_type = type(manager)
self.managers[manager_type] = manager
if in_pipeline:
self.enable_manager_in_pipelines(manager_type)
def enable_manager_in_pipelines(self, manager_type: type[Manager]):
"""Make a manager run in new pipelines"""
self.pipeline_managers.add(self.managers[manager_type])
def cleanup_game(self, game: Game) -> None:
"""Remove a game's files"""
for path in (
shared.games_dir / f"{game.game_id}.json",
shared.covers_dir / f"{game.game_id}.tiff",
shared.covers_dir / f"{game.game_id}.gif",
):
path.unlink(missing_ok=True)
def add_game(
self, game: Game, additional_data: dict, run_pipeline=True
) -> Pipeline | None:
"""Add a game to the app"""
# Ignore games from a newer spec version
if game.version > shared.SPEC_VERSION:
return None
# Scanned game is already removed, just clean it up
if game.removed:
self.cleanup_game(game)
return None
# Handle game duplicates
stored_game = self.games.get(game.game_id)
if not stored_game:
# New game, do as normal
logging.debug("New store game %s (%s)", game.name, game.game_id)
elif stored_game.removed:
# Will replace a removed game, cleanup its remains
logging.debug(
"New store game %s (%s) (replacing a removed one)",
game.name,
game.game_id,
)
self.cleanup_game(stored_game)
else:
# Duplicate game, ignore it
logging.debug("Duplicate store game %s (%s)", game.name, game.game_id)
return None
# Connect signals
for manager in self.managers.values():
for signal in manager.signals:
game.connect(signal, manager.execute_resilient_manager_logic)
# Run the pipeline for the game
if not run_pipeline:
return None
pipeline = Pipeline(game, additional_data, self.pipeline_managers)
self.games[game.game_id] = game
self.pipelines[game.game_id] = pipeline
pipeline.advance()
return pipeline

View File

@@ -1,31 +1,32 @@
# check_install.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
def check_install(check, locations, setting=None, subdirs=(Path(),)):
for location in locations:
for subdir in (Path(),) + subdirs:
if (location / subdir / check).exists():
if setting:
setting[0].set_string(setting[1], str(location / subdir))
return location / subdir
return False
# check_install.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
# TODO delegate to the sources
def check_install(check, locations, setting=None, subdirs=(Path(),)):
for location in locations:
for subdir in (Path(),) + subdirs:
if (location / subdir / check).exists():
if setting:
setting[0].set_string(setting[1], str(location / subdir))
return location / subdir
return False

View File

@@ -1,132 +0,0 @@
# importer.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from gi.repository import Adw, GLib, Gtk
from . import shared
from .create_dialog import create_dialog
from .game import Game
from .save_cover import resize_cover, save_cover
from .steamgriddb import SGDBSave
class Importer:
def __init__(self):
self.win = shared.win
self.total_queue = 0
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)
self.import_statuspage = Adw.StatusPage(
title=_("Importing Games…"),
child=self.progressbar,
)
self.import_dialog = Adw.Window(
content=self.import_statuspage,
modal=True,
default_width=350,
default_height=-1,
transient_for=self.win,
deletable=False,
)
self.import_dialog.present()
def save_game(self, values=None, cover_path=None):
if values:
game = Game(values)
if save_cover:
save_cover(game.game_id, resize_cover(cover_path))
self.games.add(game)
self.games_no += 1
if game.blacklisted:
self.games_no -= 1
self.queue -= 1
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.games, self)
else:
self.done()
def done(self):
self.update_progressbar()
if self.queue == 0:
self.import_dialog.close()
toast = Adw.Toast()
toast.set_priority(Adw.ToastPriority.HIGH)
if self.games_no == 0:
toast.set_title(_("No new games found"))
toast.set_button_label(_("Preferences"))
toast.connect(
"button-clicked", self.response, "open_preferences", "import"
)
elif self.games_no == 1:
toast.set_title(_("1 game imported"))
elif self.games_no > 1:
games_no = self.games_no
toast.set_title(
# The variable is the number of games
_("{} games imported").format(games_no)
)
self.win.toast_overlay.add_toast(toast)
# Add timeout to make it the last thing to happen
GLib.timeout_add(0, self.warning, None, None)
def response(self, _widget, response, page_name=None, expander_row=None):
if response == "open_preferences":
self.win.get_application().on_preferences_action(
None, page_name=page_name, expander_row=expander_row
)
def warning(self, *_args):
if self.sgdb_exception:
create_dialog(
self.win,
_("Couldn't Connect to SteamGridDB"),
self.sgdb_exception,
"open_preferences",
_("Preferences"),
).connect("response", self.response, "sgdb")
self.sgdb_exception = None
def update_progressbar(self):
try:
self.progressbar.set_fraction(1 - (self.queue / self.total_queue))
except ZeroDivisionError:
self.progressbar.set_fraction(1)

217
src/utils/rate_limiter.py Normal file
View File

@@ -0,0 +1,217 @@
# rate_limiter.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional, Sized
from threading import Lock, Thread, BoundedSemaphore
from time import sleep, time
from collections import deque
from contextlib import AbstractContextManager
class PickHistory(Sized):
"""Utility class used for rate limiters, counting how many picks
happened in a given period"""
period: int
timestamps: list[int] = None
timestamps_lock: Lock = None
def __init__(self, period: int) -> None:
self.period = period
self.timestamps = []
self.timestamps_lock = Lock()
def remove_old_entries(self):
"""Remove history entries older than the period"""
now = time()
cutoff = now - self.period
with self.timestamps_lock:
self.timestamps = [entry for entry in self.timestamps if entry > cutoff]
def add(self, *new_timestamps: Optional[int]):
"""Add timestamps to the history.
If none given, will add the current timestamp"""
if len(new_timestamps) == 0:
new_timestamps = (time(),)
with self.timestamps_lock:
self.timestamps.extend(new_timestamps)
def __len__(self) -> int:
"""How many entries were logged in the period"""
self.remove_old_entries()
with self.timestamps_lock:
return len(self.timestamps)
@property
def start(self) -> int:
"""Get the time at which the history started"""
self.remove_old_entries()
with self.timestamps_lock:
try:
entry = self.timestamps[0]
except IndexError:
entry = time()
return entry
def copy_timestamps(self) -> str:
"""Get a copy of the timestamps history"""
self.remove_old_entries()
with self.timestamps_lock:
return self.timestamps.copy()
# pylint: disable=too-many-instance-attributes
class RateLimiter(AbstractContextManager):
"""Rate limiter implementing the token bucket algorithm"""
# Period in which we have a max amount of tokens
refill_period_seconds: int
# Number of tokens allowed in this period
refill_period_tokens: int
# Max number of tokens that can be consumed instantly
burst_tokens: int
pick_history: PickHistory = None
bucket: BoundedSemaphore = None
queue: deque[Lock] = None
queue_lock: Lock = None
# Protect the number of tokens behind a lock
__n_tokens_lock: Lock = None
__n_tokens = 0
@property
def n_tokens(self):
with self.__n_tokens_lock:
return self.__n_tokens
@n_tokens.setter
def n_tokens(self, value: int):
with self.__n_tokens_lock:
self.__n_tokens = value
def __init__(
self,
refill_period_seconds: Optional[int] = None,
refill_period_tokens: Optional[int] = None,
burst_tokens: Optional[int] = None,
) -> None:
"""Initialize the limiter"""
# Initialize default values
if refill_period_seconds is not None:
self.refill_period_seconds = refill_period_seconds
if refill_period_tokens is not None:
self.refill_period_tokens = refill_period_tokens
if burst_tokens is not None:
self.burst_tokens = burst_tokens
if self.pick_history is None:
self.pick_history = PickHistory(self.refill_period_seconds)
# Create synchronization data
self.__n_tokens_lock = Lock()
self.queue_lock = Lock()
self.queue = deque()
# Initialize the token bucket
self.bucket = BoundedSemaphore(self.burst_tokens)
self.n_tokens = self.burst_tokens
# Spawn daemon thread that refills the bucket
refill_thread = Thread(target=self.refill_thread_func, daemon=True)
refill_thread.start()
@property
def refill_spacing(self) -> float:
"""
Get the current refill spacing.
Ensures that even with a burst in the period, the limit will not be exceeded.
"""
# Compute ideal spacing
tokens_left = self.refill_period_tokens - len(self.pick_history)
seconds_left = self.pick_history.start + self.refill_period_seconds - time()
try:
spacing_seconds = seconds_left / tokens_left
except ZeroDivisionError:
# There were no remaining tokens, gotta wait until end of the period
spacing_seconds = seconds_left
# Prevent spacing dropping down lower than the natural spacing
natural_spacing = self.refill_period_seconds / self.refill_period_tokens
return max(natural_spacing, spacing_seconds)
def refill(self):
"""Add a token back in the bucket"""
sleep(self.refill_spacing)
try:
self.bucket.release()
except ValueError:
# Bucket was full
pass
else:
self.n_tokens += 1
def refill_thread_func(self):
"""Entry point for the daemon thread that is refilling the bucket"""
while True:
self.refill()
def update_queue(self) -> None:
"""Update the queue, moving it forward if possible. Non-blocking."""
update_thread = Thread(target=self.queue_update_thread_func, daemon=True)
update_thread.start()
def queue_update_thread_func(self) -> None:
"""Queue-updating thread's entry point"""
with self.queue_lock:
if len(self.queue) == 0:
return
# Not using with because we don't want to release to the bucket
self.bucket.acquire() # pylint: disable=consider-using-with
self.n_tokens -= 1
lock = self.queue.pop()
lock.release()
def add_to_queue(self) -> Lock:
"""Create a lock, add it to the queue and return it"""
lock = Lock()
# We want the lock locked until its turn in queue
lock.acquire() # pylint: disable=consider-using-with
with self.queue_lock:
self.queue.appendleft(lock)
return lock
def acquire(self):
"""Acquires a token from the bucket when it's your turn in queue"""
lock = self.add_to_queue()
self.update_queue()
# Wait until our turn in queue
lock.acquire() # pylint: disable=consider-using-with
self.pick_history.add()
# --- Support for use in with statements
def __enter__(self):
self.acquire()
def __exit__(self, *_args):
pass

View File

@@ -0,0 +1,44 @@
# relative_date.py
#
# Copyright 2022-2023 kramo
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime
from gi.repository import GLib
def relative_date(timestamp): # pylint: disable=too-many-return-statements
days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days
if days_no == 0:
return _("Today")
if days_no == 1:
return _("Yesterday")
if days_no <= (day_of_week := today.weekday()):
return GLib.DateTime.new_from_unix_utc(timestamp).format("%A")
if days_no <= day_of_week + 7:
return _("Last Week")
if days_no <= (day_of_month := today.day):
return _("This Month")
if days_no <= day_of_month + 30:
return _("Last Month")
if days_no < (day_of_year := today.timetuple().tm_yday):
return GLib.DateTime.new_from_unix_utc(timestamp).format("%B")
if days_no <= day_of_year + 365:
return _("Last Year")
return GLib.DateTime.new_from_unix_utc(timestamp).format("%Y")

View File

@@ -22,9 +22,9 @@ from pathlib import Path
from shutil import copyfile
from gi.repository import Gio
from PIL import Image, ImageSequence
from PIL import Image, ImageSequence, UnidentifiedImageError
from . import shared
from src import shared
def resize_cover(cover_path=None, pixbuf=None):
@@ -35,32 +35,35 @@ def resize_cover(cover_path=None, pixbuf=None):
cover_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
pixbuf.savev(str(cover_path), "tiff", ["compression"], ["1"])
with Image.open(cover_path) as image:
if getattr(image, "is_animated", False):
frames = tuple(
frame.resize((200, 300)) for frame in ImageSequence.Iterator(image)
)
try:
with Image.open(cover_path) as image:
if getattr(image, "is_animated", False):
frames = tuple(
frame.resize((200, 300)) for frame in ImageSequence.Iterator(image)
)
tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path())
frames[0].save(
tmp_path,
save_all=True,
append_images=frames[1:],
)
tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path())
frames[0].save(
tmp_path,
save_all=True,
append_images=frames[1:],
)
else:
# This might not be necessary in the future
# https://github.com/python-pillow/Pillow/issues/2663
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA")
else:
# This might not be necessary in the future
# https://github.com/python-pillow/Pillow/issues/2663
if image.mode not in ("RGB", "RGBA"):
image = image.convert("RGBA")
tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
image.resize(shared.image_size).save(
tmp_path,
compression="tiff_adobe_deflate"
if shared.schema.get_boolean("high-quality-images")
else "webp",
)
tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
image.resize(shared.image_size).save(
tmp_path,
compression="tiff_adobe_deflate"
if shared.schema.get_boolean("high-quality-images")
else "webp",
)
except UnidentifiedImageError:
return None
return tmp_path

37
src/utils/sqlite.py Normal file
View File

@@ -0,0 +1,37 @@
# sqlite.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from glob import escape
from pathlib import Path
from shutil import copyfile
from gi.repository import GLib
def copy_db(original_path: Path) -> Path:
"""
Copy a sqlite database to a cache dir and return its new path.
The caller in in charge of deleting the returned path's parent dir.
"""
tmp = Path(GLib.Dir.make_tmp())
for file in original_path.parent.glob(f"{escape(original_path.name)}*"):
copy = tmp / file.name
copyfile(str(file), str(copy))
return tmp / original_path.name

152
src/utils/steam.py Normal file
View File

@@ -0,0 +1,152 @@
# steam.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
import re
from typing import TypedDict
import requests
from requests.exceptions import HTTPError
from src import shared
from src.utils.rate_limiter import PickHistory, RateLimiter
class SteamError(Exception):
pass
class SteamGameNotFoundError(SteamError):
pass
class SteamNotAGameError(SteamError):
pass
class SteamInvalidManifestError(SteamError):
pass
class SteamManifestData(TypedDict):
"""Dict returned by SteamFileHelper.get_manifest_data"""
name: str
appid: str
stateflags: str
class SteamAPIData(TypedDict):
"""Dict returned by SteamAPIHelper.get_api_data"""
developers: str
class SteamRateLimiter(RateLimiter):
"""Rate limiter for the Steam web API"""
# Steam web API limit
# 200 requests per 5 min seems to be the limit
# https://stackoverflow.com/questions/76047820/how-am-i-exceeding-steam-apis-rate-limit
# https://stackoverflow.com/questions/51795457/avoiding-error-429-too-many-requests-steam-web-api
refill_period_seconds = 5 * 60
refill_period_tokens = 200
burst_tokens = 100
def __init__(self) -> None:
# Load pick history from schema
# (Remember API limits through restarts of Cartridges)
timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history")
self.pick_history = PickHistory(self.refill_period_seconds)
self.pick_history.add(*json.loads(timestamps_str))
self.pick_history.remove_old_entries()
super().__init__()
def acquire(self):
"""Get a token from the bucket and store the pick history in the schema"""
super().acquire()
timestamps_str = json.dumps(self.pick_history.copy_timestamps())
shared.state_schema.set_string("steam-limiter-tokens-history", timestamps_str)
class SteamFileHelper:
"""Helper for steam file formats"""
def get_manifest_data(self, manifest_path) -> SteamManifestData:
"""Get local data for a game from its manifest"""
with open(manifest_path, "r", encoding="utf-8") as file:
contents = file.read()
data = {}
for key in SteamManifestData.__required_keys__: # pylint: disable=no-member
regex = f'"{key}"\\s+"(.*)"\n'
if (match := re.search(regex, contents, re.IGNORECASE)) is None:
raise SteamInvalidManifestError()
data[key] = match.group(1)
return SteamManifestData(**data)
class SteamAPIHelper:
"""Helper around the Steam API"""
base_url = "https://store.steampowered.com/api"
rate_limiter: RateLimiter
def __init__(self, rate_limiter: RateLimiter) -> None:
self.rate_limiter = rate_limiter
def get_api_data(self, appid) -> SteamAPIData:
"""
Get online data for a game from its appid.
May block to satisfy the Steam web API limitations.
See https://wiki.teamfortress.com/wiki/User:RJackson/StorefrontAPI#appdetails
"""
# Get data from the API (way block to satisfy its limits)
with self.rate_limiter:
try:
with requests.get(
f"{self.base_url}/appdetails?appids={appid}", timeout=5
) as response:
response.raise_for_status()
data = response.json()[appid]
except HTTPError as error:
logging.warning("Steam API HTTP error for %s", appid, exc_info=error)
raise error
# Handle not found
if not data["success"]:
logging.debug("Appid %s not found", appid)
raise SteamGameNotFoundError()
# Handle appid is not a game
game_types = ("game", "demo")
if data["data"]["type"] not in game_types:
logging.debug("Appid %s is not a game", appid)
raise SteamNotAGameError()
# Return API values we're interested in
values = SteamAPIData(developers=", ".join(data["data"]["developers"]))
return values

View File

@@ -1,128 +1,157 @@
# steamgriddb.py
#
# Copyright 2022-2023 kramo
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import logging
from pathlib import Path
import requests
from gi.repository import Gio
from requests.exceptions import HTTPError
from . import shared
from .create_dialog import create_dialog
from .save_cover import save_cover, resize_cover
from src import shared
from src.utils.save_cover import resize_cover, save_cover
class SGDBSave:
def __init__(self, games, importer=None):
self.win = shared.win
self.importer = importer
self.exception = None
class SGDBError(Exception):
pass
# 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, *_args):
self.update_cover(
task,
game,
)
return wrapper
class SGDBAuthError(SGDBError):
pass
for game in games:
Gio.Task.new(None, None, self.task_done).run_in_thread(create_func(game))
def update_cover(self, task, game):
game.set_loading(1)
class SGDBGameNotFoundError(SGDBError):
pass
if (
not (
shared.schema.get_boolean("sgdb")
and (
(shared.schema.get_boolean("sgdb-prefer"))
or not (
(shared.covers_dir / f"{game.game_id}.gif").is_file()
or (shared.covers_dir / f"{game.game_id}.tiff").is_file()
)
)
)
or game.blacklisted
):
task.return_value(game)
class SGDBBadRequestError(SGDBError):
pass
class SGDBNoImageFoundError(SGDBError):
pass
class SGDBHelper:
"""Helper class to make queries to SteamGridDB"""
base_url = "https://www.steamgriddb.com/api/v2/"
@property
def auth_headers(self):
key = shared.schema.get_string("sgdb-key")
headers = {"Authorization": f"Bearer {key}"}
return headers
def get_game_id(self, game):
"""Get grid results for a game. Can raise an exception."""
uri = f"{self.base_url}search/autocomplete/{game.name}"
res = requests.get(uri, headers=self.auth_headers, timeout=5)
match res.status_code:
case 200:
return res.json()["data"][0]["id"]
case 401:
raise SGDBAuthError(res.json()["errors"][0])
case 404:
raise SGDBGameNotFoundError(res.status_code)
case _:
res.raise_for_status()
def get_image_uri(self, game_id, animated=False):
"""Get the image for a SGDB game id"""
uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900"
if animated:
uri += "&types=animated"
res = requests.get(uri, headers=self.auth_headers, timeout=5)
match res.status_code:
case 200:
data = res.json()["data"]
if len(data) == 0:
raise SGDBNoImageFoundError()
return data[0]["url"]
case 401:
raise SGDBAuthError(res.json()["errors"][0])
case 404:
raise SGDBGameNotFoundError(res.status_code)
case _:
res.raise_for_status()
def conditionaly_update_cover(self, game):
"""Update the game's cover if appropriate"""
# Obvious skips
use_sgdb = shared.schema.get_boolean("sgdb")
if not use_sgdb or game.blacklisted:
return
url = "https://www.steamgriddb.com/api/v2/"
headers = {"Authorization": f'Bearer {shared.schema.get_string("sgdb-key")}'}
image_trunk = shared.covers_dir / game.game_id
still = image_trunk.with_suffix(".tiff")
uri_kwargs = image_trunk.with_suffix(".gif")
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
# Do nothing if file present and not prefer SGDB
if not prefer_sgdb and (still.is_file() or uri_kwargs.is_file()):
return
# Get ID for the game
try:
search_result = requests.get(
f"{url}search/autocomplete/{game.name}",
headers=headers,
timeout=5,
sgdb_id = self.get_game_id(game)
except (HTTPError, SGDBError) as error:
logging.warning(
"%s while getting SGDB ID for %s", type(error).__name__, game.name
)
if search_result.status_code != 200:
self.exception = str(
search_result.json()["errors"][0]
if "errors" in tuple(search_result.json())
else search_result.status_code
raise error
# Build different SGDB options to try
image_uri_kwargs_sets = [{"animated": False}]
if shared.schema.get_boolean("sgdb-animated"):
image_uri_kwargs_sets.insert(0, {"animated": True})
# Download covers
for uri_kwargs in image_uri_kwargs_sets:
try:
uri = self.get_image_uri(sgdb_id, **uri_kwargs)
response = requests.get(uri, timeout=5)
tmp_file = Gio.File.new_tmp()[0]
tmp_file_path = tmp_file.get_path()
Path(tmp_file_path).write_bytes(response.content)
save_cover(game.game_id, resize_cover(tmp_file_path))
except SGDBAuthError as error:
# Let caller handle auth errors
raise error
except (HTTPError, SGDBError) as error:
logging.warning(
"%s while getting image for %s kwargs=%s",
type(error).__name__,
game.name,
str(uri_kwargs),
)
search_result.raise_for_status()
except requests.exceptions.RequestException:
task.return_value(game)
return
continue
else:
# Stop as soon as one is finished
return
response = None
try:
if shared.schema.get_boolean("sgdb-animated"):
try:
grid = requests.get(
f'{url}grids/game/{search_result.json()["data"][0]["id"]}?dimensions=600x900&types=animated',
headers=headers,
timeout=5,
)
response = requests.get(grid.json()["data"][0]["url"], timeout=5)
except IndexError:
pass
if not response:
grid = requests.get(
f'{url}grids/game/{search_result.json()["data"][0]["id"]}?dimensions=600x900',
headers=headers,
timeout=5,
)
response = requests.get(grid.json()["data"][0]["url"], timeout=5)
except (requests.exceptions.RequestException, IndexError):
task.return_value(game)
return
tmp_file = Gio.File.new_tmp()[0]
Path(tmp_file.get_path()).write_bytes(response.content)
save_cover(
game.game_id,
resize_cover(tmp_file.get_path()),
# No image was added
logging.warning(
'No matching image found for game "%s" (SGDB ID %d)',
game.name,
sgdb_id,
)
task.return_value(game)
def task_done(self, _task, result):
if self.importer:
self.importer.queue -= 1
self.importer.done()
self.importer.sgdb_exception = self.exception
if self.exception and not self.importer:
create_dialog(
self.win,
_("Couldn't Connect to SteamGridDB"),
self.exception,
"open_preferences",
_("Preferences"),
).connect("response", self.response)
game = result.propagate_value()[1]
game.set_loading(-1)
if self.importer:
game.save()
else:
game.update()
def response(self, _widget, response):
if response == "open_preferences":
self.win.get_application().on_preferences_action(page_name="sgdb")
raise SGDBNoImageFoundError()

86
src/utils/task.py Normal file
View File

@@ -0,0 +1,86 @@
# task.py
#
# Copyright 2023 Geoffrey Coulaud
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from functools import wraps
from gi.repository import Gio
def create_task_thread_func_closure(func, data):
"""Wrap a Gio.TaskThreadFunc with the given data in a closure"""
def closure(task, source_object, _data, cancellable):
func(task, source_object, data, cancellable)
return closure
def decorate_set_task_data(task):
"""Decorate Gio.Task.set_task_data to replace it"""
def decorator(original_method):
@wraps(original_method)
def new_method(task_data):
task.task_data = task_data
return new_method
return decorator
def decorate_run_in_thread(task):
"""Decorate Gio.Task.run_in_thread to pass the task data correctly
Creates a closure around task_thread_func with the task data available."""
def decorator(original_method):
@wraps(original_method)
def new_method(task_thread_func):
closure = create_task_thread_func_closure(task_thread_func, task.task_data)
original_method(closure)
return new_method
return decorator
# pylint: disable=too-few-public-methods
class Task:
"""Wrapper around Gio.Task to patch task data not being passed"""
@classmethod
def new(cls, source_object, cancellable, callback, callback_data):
"""Create a new, monkey-patched Gio.Task.
The `set_task_data` and `run_in_thread` methods are decorated.
As of 2023-05-19, pygobject does not work well with Gio.Task, so to pass data
the only viable way it to create a closure with the thread function and its data.
This class is supposed to make Gio.Task comply with its expected behaviour
per the docs:
http://lazka.github.io/pgi-docs/#Gio-2.0/classes/Task.html#Gio.Task.set_task_data
This code may break if pygobject overrides change in the future.
We need to manually pass `self` to the decorators since it's otherwise bound but
not accessible from Python's side.
"""
task = Gio.Task.new(source_object, cancellable, callback, callback_data)
task.set_task_data = decorate_set_task_data(task)(task.set_task_data)
task.run_in_thread = decorate_run_in_thread(task)(task.run_in_thread)
return task

View File

@@ -17,13 +17,10 @@
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
from datetime import datetime
from gi.repository import Adw, Gtk
from gi.repository import Adw, GLib, Gtk
from . import shared
from .game import Game
from src import shared
from src.utils.relative_date import relative_date
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/window.ui")
@@ -77,8 +74,6 @@ class CartridgesWindow(Adw.ApplicationWindow):
def __init__(self, **kwargs):
super().__init__(**kwargs)
shared.win = self
self.previous_page = self.library_view
self.details_view.set_measure_overlay(self.details_view_box, True)
@@ -94,31 +89,9 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.notice_empty.set_icon_name(shared.APP_ID + "-symbolic")
if "Devel" in shared.APP_ID:
if shared.PROFILE == "development":
self.add_css_class("devel")
games = {}
if shared.games_dir.is_dir():
for open_file in shared.games_dir.iterdir():
data = json.load(open_file.open())
games[data["game_id"]] = data
for game_id, game in games.items():
if (version := game.get("version")) and version > shared.SPEC_VERSION:
continue
if game.get("removed"):
for path in (
shared.games_dir / f"{game_id}.json",
shared.covers_dir / f"{game_id}.tiff",
shared.covers_dir / f"{game_id}.gif",
):
path.unlink(missing_ok=True)
else:
Game(game).update()
# Connect search entries
self.search_bar.connect_entry(self.search_entry)
self.hidden_search_bar.connect_entry(self.hidden_search_entry)
@@ -184,19 +157,6 @@ class CartridgesWindow(Adw.ApplicationWindow):
def set_active_game(self, _widget, _pspec, game):
self.active_game = game
def get_time(self, timestamp):
days_no = (datetime.today() - datetime.fromtimestamp(timestamp)).days
if days_no == 0:
return _("Today")
if days_no == 1:
return _("Yesterday")
if days_no < 8:
return GLib.DateTime.new_from_unix_utc(timestamp).format("%A")
if days_no < 335:
return GLib.DateTime.new_from_unix_utc(timestamp).format("%B")
return GLib.DateTime.new_from_unix_utc(timestamp).format("%Y")
def show_details_view(self, game):
self.active_game = game
@@ -219,20 +179,20 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.details_view_game_cover = game.game_cover
self.details_view_game_cover.add_picture(self.details_view_cover)
self.details_view_blurred_cover.set_pixbuf(
self.details_view_blurred_cover.set_paintable(
self.details_view_game_cover.get_blurred()
)
self.details_view_title.set_label(game.name)
self.details_view_header_bar_title.set_title(game.name)
date = self.get_time(game.added)
date = relative_date(game.added)
self.details_view_added.set_label(
# The variable is the date when the game was added
_("Added: {}").format(date)
)
last_played_date = (
self.get_time(game.last_played) if game.last_played != 0 else _("Never")
relative_date(game.last_played) if game.last_played else _("Never")
)
self.details_view_last_played.set_label(
# The variable is the date when the game was last played
@@ -365,6 +325,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
elif undo == "remove":
game.removed = False
game.save()
game.update()
self.toasts[(game, undo)].dismiss()
self.toasts.pop((game, undo))