diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..e2f5b68 --- /dev/null +++ b/.pylintrc @@ -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=_ \ No newline at end of file diff --git a/data/cartridges.gresource.xml.in b/data/cartridges.gresource.xml.in index 203edea..472f6fb 100644 --- a/data/cartridges.gresource.xml.in +++ b/data/cartridges.gresource.xml.in @@ -5,9 +5,10 @@ gtk/help-overlay.ui gtk/game.ui gtk/preferences.ui - gtk/details_window.ui + gtk/details-window.ui gtk/style.css gtk/style-dark.css library_placeholder.svg + library_placeholder_small.svg diff --git a/data/gtk/details_window.blp b/data/gtk/details-window.blp similarity index 94% rename from data/gtk/details_window.blp rename to data/gtk/details-window.blp index 8ca1cab..ecddb13 100644 --- a/data/gtk/details_window.blp +++ b/data/gtk/details-window.blp @@ -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; } }; diff --git a/data/gtk/preferences.blp b/data/gtk/preferences.blp index 5026889..eacd33c 100644 --- a/data/gtk/preferences.blp +++ b/data/gtk/preferences.blp @@ -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; } diff --git a/data/hu.kramo.Cartridges.gschema.xml.in b/data/hu.kramo.Cartridges.gschema.xml.in index a31e95a..4738807 100644 --- a/data/hu.kramo.Cartridges.gschema.xml.in +++ b/data/hu.kramo.Cartridges.gschema.xml.in @@ -1,9 +1,9 @@ - - - false - + + + false + false @@ -32,21 +32,21 @@ true - "~/.var/app/com.heroicgameslauncher.hgl/config/heroic/" + "~/.config/heroic/" + + + true + + + true + + + true - - true - - - true - - - true - true - + "~/.var/app/com.usebottles.bottles/data/bottles/" @@ -55,6 +55,12 @@ "~/.var/app/io.itch.itch/config/itch/" + + true + + + "~/.config/legendary/" + "" @@ -67,7 +73,7 @@ false - + 1110 @@ -80,13 +86,16 @@ - - - - - + + + + + "a-z" + + "[]" + - + \ No newline at end of file diff --git a/data/library_placeholder_small.svg b/data/library_placeholder_small.svg new file mode 100644 index 0000000..541aec1 --- /dev/null +++ b/data/library_placeholder_small.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/data/meson.build b/data/meson.build index 071fb69..20512b3 100644 --- a/data/meson.build +++ b/data/meson.build @@ -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@'], diff --git a/docs/game_id.json.md b/docs/game_id.json.md new file mode 100644 index 0000000..c7e879b --- /dev/null +++ b/docs/game_id.json.md @@ -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. \ No newline at end of file diff --git a/meson.build b/meson.build index cf6d71a..f58ac1f 100644 --- a/meson.build +++ b/meson.build @@ -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) diff --git a/po/POTFILES b/po/POTFILES index 271d33c..6b25bb0 100644 --- a/po/POTFILES +++ b/po/POTFILES @@ -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 \ No newline at end of file diff --git a/po/cartridges.pot b/po/cartridges.pot index 992daec..7117cf0 100644 --- a/po/cartridges.pot +++ b/po/cartridges.pot @@ -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 \n" "Language-Team: LANGUAGE \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 "" diff --git a/src/__init__.py b/src/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src/cartridges.in b/src/cartridges.in index a5be237..67e82e7 100755 --- a/src/cartridges.in +++ b/src/cartridges.in @@ -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)) diff --git a/src/details_window.py b/src/details_window.py index 2d03cc5..dd7267b 100644 --- a/src/details_window.py +++ b/src/details_window.py @@ -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"{}"\n\nTo open the file "{}" with the default application, use:\n\n{} "{}"\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() diff --git a/src/errors/error_producer.py b/src/errors/error_producer.py new file mode 100644 index 0000000..7f31fea --- /dev/null +++ b/src/errors/error_producer.py @@ -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 diff --git a/src/errors/friendly_error.py b/src/errors/friendly_error.py new file mode 100644 index 0000000..4c9ca7f --- /dev/null +++ b/src/errors/friendly_error.py @@ -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}" diff --git a/src/game.py b/src/game.py index d0c68af..ce76216 100644 --- a/src/game.py +++ b/src/game.py @@ -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""" diff --git a/src/game_cover.py b/src/game_cover.py index 46065f9..662094d 100644 --- a/src/game_cover.py +++ b/src/game_cover.py @@ -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( diff --git a/src/importer/importer.py b/src/importer/importer.py new file mode 100644 index 0000000..0c7b309 --- /dev/null +++ b/src/importer/importer.py @@ -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 . +# +# 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() diff --git a/src/importer/sources/bottles_source.py b/src/importer/sources/bottles_source.py new file mode 100644 index 0000000..1136334 --- /dev/null +++ b/src/importer/sources/bottles_source.py @@ -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 . +# +# 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"), + }, + ) diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py new file mode 100644 index 0000000..17d5da2 --- /dev/null +++ b/src/importer/sources/heroic_source.py @@ -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 . +# +# 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}" diff --git a/src/importer/sources/itch_source.py b/src/importer/sources/itch_source.py new file mode 100644 index 0000000..df861fd --- /dev/null +++ b/src/importer/sources/itch_source.py @@ -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 . +# +# 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")}, + ) diff --git a/src/importer/sources/legendary_source.py b/src/importer/sources/legendary_source.py new file mode 100644 index 0000000..4806c24 --- /dev/null +++ b/src/importer/sources/legendary_source.py @@ -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 . +# +# 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"), + }, + ) diff --git a/src/importer/sources/location.py b/src/importer/sources/location.py new file mode 100644 index 0000000..8374a20 --- /dev/null +++ b/src/importer/sources/location.py @@ -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] diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py new file mode 100644 index 0000000..1fa0aa0 --- /dev/null +++ b/src/importer/sources/lutris_source.py @@ -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 . +# +# 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}" diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py new file mode 100644 index 0000000..a26b487 --- /dev/null +++ b/src/importer/sources/source.py @@ -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 . +# +# 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}" + ) diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py new file mode 100644 index 0000000..aed6328 --- /dev/null +++ b/src/importer/sources/steam_source.py @@ -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 . +# +# 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"), + }, + ) diff --git a/src/importers/bottles_importer.py b/src/importers/bottles_importer.py deleted file mode 100644 index 6c32910..0000000 --- a/src/importers/bottles_importer.py +++ /dev/null @@ -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 . -# -# 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, - ) diff --git a/src/importers/heroic_importer.py b/src/importers/heroic_importer.py deleted file mode 100644 index 001bde2..0000000 --- a/src/importers/heroic_importer.py +++ /dev/null @@ -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 . -# -# 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) diff --git a/src/importers/itch_importer.py b/src/importers/itch_importer.py deleted file mode 100644 index 4b44d1f..0000000 --- a/src/importers/itch_importer.py +++ /dev/null @@ -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 . -# -# 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) diff --git a/src/importers/lutris_importer.py b/src/importers/lutris_importer.py deleted file mode 100644 index c2f3030..0000000 --- a/src/importers/lutris_importer.py +++ /dev/null @@ -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 . -# -# 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) diff --git a/src/importers/steam_importer.py b/src/importers/steam_importer.py deleted file mode 100644 index db7ea9e..0000000 --- a/src/importers/steam_importer.py +++ /dev/null @@ -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 . -# -# 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) diff --git a/src/logging/color_log_formatter.py b/src/logging/color_log_formatter.py new file mode 100644 index 0000000..53fe261 --- /dev/null +++ b/src/logging/color_log_formatter.py @@ -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 . +# +# 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 diff --git a/src/logging/session_file_handler.py b/src/logging/session_file_handler.py new file mode 100644 index 0000000..156be71 --- /dev/null +++ b/src/logging/session_file_handler.py @@ -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 . +# +# 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() diff --git a/src/logging/setup.py b/src/logging/setup.py new file mode 100644 index 0000000..ab682f8 --- /dev/null +++ b/src/logging/setup.py @@ -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 . +# +# 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) diff --git a/src/main.py b/src/main.py index 064ef16..29252e8 100644 --- a/src/main.py +++ b/src/main.py @@ -16,7 +16,7 @@ # along with this program. If not, see . # # 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) diff --git a/src/meson.build b/src/meson.build index 5b94f5b..5bb4a75 100644 --- a/src/meson.build +++ b/src/meson.build @@ -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 +) \ No newline at end of file diff --git a/src/preferences.py b/src/preferences.py index adbd833..dc25957 100644 --- a/src/preferences.py +++ b/src/preferences.py @@ -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) diff --git a/src/shared.py.in b/src/shared.py.in index 0060914..d7d60a7 100644 --- a/src/shared.py.in +++ b/src/shared.py.in @@ -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 \ No newline at end of file diff --git a/src/store/managers/async_manager.py b/src/store/managers/async_manager.py new file mode 100644 index 0000000..153ce82 --- /dev/null +++ b/src/store/managers/async_manager.py @@ -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 . +# +# 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) diff --git a/src/store/managers/display_manager.py b/src/store/managers/display_manager.py new file mode 100644 index 0000000..c8acf0d --- /dev/null +++ b/src/store/managers/display_manager.py @@ -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 . +# +# 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() diff --git a/src/store/managers/file_manager.py b/src/store/managers/file_manager.py new file mode 100644 index 0000000..4caa3b4 --- /dev/null +++ b/src/store/managers/file_manager.py @@ -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 . +# +# 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, + ) diff --git a/src/store/managers/local_cover_manager.py b/src/store/managers/local_cover_manager.py new file mode 100644 index 0000000..9aa8703 --- /dev/null +++ b/src/store/managers/local_cover_manager.py @@ -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 . +# +# 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)) diff --git a/src/store/managers/manager.py b/src/store/managers/manager.py new file mode 100644 index 0000000..fbf5556 --- /dev/null +++ b/src/store/managers/manager.py @@ -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 . +# +# 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) diff --git a/src/store/managers/online_cover_manager.py b/src/store/managers/online_cover_manager.py new file mode 100644 index 0000000..7398169 --- /dev/null +++ b/src/store/managers/online_cover_manager.py @@ -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 . +# +# 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 + ) diff --git a/src/store/managers/sgdb_manager.py b/src/store/managers/sgdb_manager.py new file mode 100644 index 0000000..142495f --- /dev/null +++ b/src/store/managers/sgdb_manager.py @@ -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 . +# +# 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 diff --git a/src/store/managers/steam_api_manager.py b/src/store/managers/steam_api_manager.py new file mode 100644 index 0000000..75fa416 --- /dev/null +++ b/src/store/managers/steam_api_manager.py @@ -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 . +# +# 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) diff --git a/src/store/pipeline.py b/src/store/pipeline.py new file mode 100644 index 0000000..f3f2883 --- /dev/null +++ b/src/store/pipeline.py @@ -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 . +# +# 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""" diff --git a/src/store/store.py b/src/store/store.py new file mode 100644 index 0000000..d56a746 --- /dev/null +++ b/src/store/store.py @@ -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 . +# +# 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 diff --git a/src/utils/check_install.py b/src/utils/check_install.py index 5e213f8..2f74395 100644 --- a/src/utils/check_install.py +++ b/src/utils/check_install.py @@ -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 . -# -# 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 . +# +# 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 diff --git a/src/utils/importer.py b/src/utils/importer.py deleted file mode 100644 index 79ac849..0000000 --- a/src/utils/importer.py +++ /dev/null @@ -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 . -# -# 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) diff --git a/src/utils/rate_limiter.py b/src/utils/rate_limiter.py new file mode 100644 index 0000000..09b17a9 --- /dev/null +++ b/src/utils/rate_limiter.py @@ -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 . +# +# 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 diff --git a/src/utils/relative_date.py b/src/utils/relative_date.py new file mode 100644 index 0000000..6ecde67 --- /dev/null +++ b/src/utils/relative_date.py @@ -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 . +# +# 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") diff --git a/src/utils/save_cover.py b/src/utils/save_cover.py index 620466b..f405977 100644 --- a/src/utils/save_cover.py +++ b/src/utils/save_cover.py @@ -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 diff --git a/src/utils/sqlite.py b/src/utils/sqlite.py new file mode 100644 index 0000000..c605dff --- /dev/null +++ b/src/utils/sqlite.py @@ -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 . +# +# 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 diff --git a/src/utils/steam.py b/src/utils/steam.py new file mode 100644 index 0000000..25a1584 --- /dev/null +++ b/src/utils/steam.py @@ -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 . +# +# 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 diff --git a/src/utils/steamgriddb.py b/src/utils/steamgriddb.py index 095e913..9cd9c3c 100644 --- a/src/utils/steamgriddb.py +++ b/src/utils/steamgriddb.py @@ -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 . +# +# 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() diff --git a/src/utils/task.py b/src/utils/task.py new file mode 100644 index 0000000..190d7c4 --- /dev/null +++ b/src/utils/task.py @@ -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 . +# +# 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 diff --git a/src/window.py b/src/window.py index 6faede9..9394020 100644 --- a/src/window.py +++ b/src/window.py @@ -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))