Compare commits

..

5 Commits

Author SHA1 Message Date
Rilic
0440eee5d4 Convert to new importer format 2023-08-03 20:32:28 +01:00
Rilic
436a54ba5b Merge remote-tracking branch 'upstream/main' into dolphin-importer 2023-08-03 19:30:16 +01:00
Rilic
b378110779 Launch Dolphin games without main Dolphin window 2023-07-25 15:11:38 +01:00
Rilic
5708f48db8 Finish Dolphin importer, fix cache reader bug 2023-07-23 22:22:18 +01:00
Rilic
9618fb7fff Implement initial framework for Dolphin importer
- Uses cache reading code from Lutris by strycore. https://github.com/lutris/lutris/blob/master/lutris/util/dolphin/cache_reader.py#L23
2023-07-23 20:24:09 +01:00
45 changed files with 918 additions and 1154 deletions

View File

@@ -2,7 +2,7 @@ using Gtk 4.0;
using Adw 1; using Adw 1;
template $DetailsWindow : Adw.Window { template $DetailsWindow : Adw.Window {
default-width: 480; // Same as Nautilus' properties window default-width: 500;
default-height: -1; default-height: -1;
modal: true; modal: true;
@@ -97,26 +97,39 @@ template $DetailsWindow : Adw.Window {
} }
} }
Adw.PreferencesGroup { Adw.PreferencesGroup title_group {
Adw.EntryRow name { title: _("Title");
title: _("Title"); description: _("The title of the game");
}
Adw.EntryRow developer { Entry name {
title: _("Developer (optional)"); accessibility {
label: _("Title");
}
} }
} }
Adw.PreferencesGroup {
Adw.EntryRow executable {
title: _("Executable");
[suffix] Adw.PreferencesGroup developer_group {
Gtk.MenuButton exec_info_button { title: _("Developer");
description: _("The developer or publisher (optional)");
Entry developer {
accessibility {
label: _("Developer");
}
}
}
Adw.PreferencesGroup exec_group {
title: _("Executable");
description: _("File to open or command to run when launching the game");
[header-suffix]
Gtk.MenuButton exec_info_button {
valign: center; valign: center;
icon-name: "help-about-symbolic"; icon-name: "help-about-symbolic";
tooltip-text: _("More Info"); tooltip-text: _("More Info");
popover: Popover exec_info_popover { popover: Popover exec_info_popover {
focusable: true;
Label exec_info_label { Label exec_info_label {
use-markup: true; use-markup: true;
@@ -128,6 +141,7 @@ template $DetailsWindow : Adw.Window {
margin-bottom: 6; margin-bottom: 6;
margin-start: 6; margin-start: 6;
margin-end: 6; margin-end: 6;
selectable: true;
} }
}; };
@@ -136,6 +150,10 @@ template $DetailsWindow : Adw.Window {
] ]
} }
Entry executable {
accessibility {
label: _("Executable");
}
} }
} }
} }

View File

@@ -85,19 +85,6 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Import"); title: _("Import");
icon-name: "document-save-symbolic"; icon-name: "document-save-symbolic";
Adw.PreferencesGroup import_behavior_group {
title: _("Behavior");
Adw.ActionRow {
title: _("Remove Uninstalled Games");
activatable-widget: remove_missing_switch;
Switch remove_missing_switch {
valign: center;
}
}
}
Adw.PreferencesGroup sources_group { Adw.PreferencesGroup sources_group {
title: _("Sources"); title: _("Sources");
@@ -220,6 +207,20 @@ template $PreferencesWindow : Adw.PreferencesWindow {
} }
} }
Adw.ExpanderRow dolphin_expander_row {
title: _("Dolphin");
show-enable-switch: true;
Adw.ActionRow dolphin_cache_action_row {
title: _("Cache Location");
Button dolphin_cache_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
}
Adw.ExpanderRow itch_expander_row { Adw.ExpanderRow itch_expander_row {
title: _("itch"); title: _("itch");
show-enable-switch: true; show-enable-switch: true;
@@ -248,20 +249,6 @@ template $PreferencesWindow : Adw.PreferencesWindow {
} }
} }
Adw.ExpanderRow retroarch_expander_row {
title: _("RetroArch");
show-enable-switch: true;
Adw.ActionRow retroarch_config_action_row {
title: _("Install Location");
Button retroarch_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
}
Adw.ExpanderRow flatpak_expander_row { Adw.ExpanderRow flatpak_expander_row {
title: _("Flatpak"); title: _("Flatpak");
show-enable-switch: true; show-enable-switch: true;

View File

@@ -269,7 +269,6 @@ template $CartridgesWindow : Adw.ApplicationWindow {
tightening-threshold: 500; tightening-threshold: 500;
SearchEntry search_entry { SearchEntry search_entry {
placeholder-text: _("Search games");
hexpand: true; hexpand: true;
} }
} }
@@ -336,7 +335,6 @@ template $CartridgesWindow : Adw.ApplicationWindow {
tightening-threshold: 500; tightening-threshold: 500;
SearchEntry hidden_search_entry { SearchEntry hidden_search_entry {
placeholder-text: _("Search hidden games");
hexpand: true; hexpand: true;
} }
} }

View File

@@ -7,5 +7,5 @@ Icon=@APP_ID@
Terminal=false Terminal=false
Type=Application Type=Application
Categories=GNOME;GTK;Game; Categories=GNOME;GTK;Game;
Keywords=gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;retroarch; Keywords=gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;
StartupNotify=true StartupNotify=true

View File

@@ -10,9 +10,6 @@
<key name="high-quality-images" type="b"> <key name="high-quality-images" type="b">
<default>false</default> <default>false</default>
</key> </key>
<key name="remove-missing" type="b">
<default>true</default>
</key>
<key name="steam" type="b"> <key name="steam" type="b">
<default>true</default> <default>true</default>
</key> </key>
@@ -58,6 +55,12 @@
<key name="bottles-location" type="s"> <key name="bottles-location" type="s">
<default>"~/.var/app/com.usebottles.bottles/data/bottles/"</default> <default>"~/.var/app/com.usebottles.bottles/data/bottles/"</default>
</key> </key>
<key name="dolphin" type="b">
<default>true</default>
</key>
<key name="dolphin-cache-location" type="s">
<default>"~/.var/app/org.DolphinEmu.dolphin-emu/cache/dolphin-emu/"</default>
</key>
<key name="itch" type="b"> <key name="itch" type="b">
<default>true</default> <default>true</default>
</key> </key>
@@ -70,12 +73,6 @@
<key name="legendary-location" type="s"> <key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default> <default>"~/.config/legendary/"</default>
</key> </key>
<key name="retroarch" type="b">
<default>true</default>
</key>
<key name="retroarch-location" type="s">
<default>"~/.var/app/org.libretro.RetroArch/config/retroarch/"</default>
</key>
<key name="flatpak" type="b"> <key name="flatpak" type="b">
<default>true</default> <default>true</default>
</key> </key>

View File

@@ -12,13 +12,13 @@
"--socket=wayland", "--socket=wayland",
"--talk-name=org.freedesktop.Flatpak", "--talk-name=org.freedesktop.Flatpak",
"--filesystem=host:ro", "--filesystem=host:ro",
"--filesystem=~/.var/app/org.DolphinEmu.dolphin-emu:ro",
"--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro", "--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro",
"--filesystem=~/.var/app/net.lutris.Lutris/:ro", "--filesystem=~/.var/app/net.lutris.Lutris/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro", "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/legendary/:ro", "--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/legendary/:ro",
"--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro", "--filesystem=~/.var/app/com.usebottles.bottles/data/bottles/:ro",
"--filesystem=~/.var/app/io.itch.itch/config/itch/:ro", "--filesystem=~/.var/app/io.itch.itch/config/itch/:ro",
"--filesystem=~/.var/app/org.libretro.RetroArch/config/retroarch/:ro",
"--filesystem=/var/lib/flatpak:ro" "--filesystem=/var/lib/flatpak:ro"
], ],
"cleanup" : [ "cleanup" : [

View File

@@ -15,7 +15,5 @@ src/game.py
src/preferences.py src/preferences.py
src/utils/create_dialog.py src/utils/create_dialog.py
src/importer/importer.py
src/importer/sources/source.py src/importer/sources/source.py
src/importer/sources/location.py
src/store/managers/sgdb_manager.py src/store/managers/sgdb_manager.py

View File

@@ -8,18 +8,18 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: Cartridges\n" "Project-Id-Version: Cartridges\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-08-16 11:06+0200\n" "POT-Creation-Date: 2023-07-25 20:33+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n" "Language: \n"
"MIME-Version: 1.0\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" "Content-Transfer-Encoding: 8bit\n"
#: data/hu.kramo.Cartridges.desktop.in:3 #: data/hu.kramo.Cartridges.desktop.in:3
#: data/hu.kramo.Cartridges.metainfo.xml.in:6 data/gtk/window.blp:47 #: data/hu.kramo.Cartridges.metainfo.xml.in:6 data/gtk/window.blp:47
#: src/main.py:169 #: src/main.py:170
msgid "Cartridges" msgid "Cartridges"
msgstr "" msgstr ""
@@ -33,8 +33,7 @@ msgid "Launch all your games"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.desktop.in:11 #: data/hu.kramo.Cartridges.desktop.in:11
msgid "" msgid "gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;"
"gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;retroarch;"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:9 #: data/hu.kramo.Cartridges.metainfo.xml.in:9
@@ -49,18 +48,16 @@ msgstr ""
msgid "Library" msgid "Library"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:34 #: data/hu.kramo.Cartridges.metainfo.xml.in:34 src/details_window.py:67
msgid "Edit Game Details" msgid "Edit Game Details"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:38 data/gtk/window.blp:71 #: data/hu.kramo.Cartridges.metainfo.xml.in:38 data/gtk/window.blp:71
#: src/details_window.py:67
msgid "Game Details" msgid "Game Details"
msgstr "" msgstr ""
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:418 #: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:416
#: src/details_window.py:241 src/importer/importer.py:292 #: src/details_window.py:241
#: src/importer/importer.py:342
msgid "Preferences" msgid "Preferences"
msgstr "" msgstr ""
@@ -76,19 +73,32 @@ msgstr ""
msgid "Delete Cover" msgid "Delete Cover"
msgstr "" msgstr ""
#: data/gtk/details-window.blp:102 data/gtk/game.blp:80 #: data/gtk/details-window.blp:101 data/gtk/details-window.blp:106
#: data/gtk/game.blp:80
msgid "Title" msgid "Title"
msgstr "" msgstr ""
#: data/gtk/details-window.blp:105 #: data/gtk/details-window.blp:102
msgid "Developer (optional)" msgid "The title of the game"
msgstr "" msgstr ""
#: data/gtk/details-window.blp:110 #: data/gtk/details-window.blp:112 data/gtk/details-window.blp:117
msgid "Developer"
msgstr ""
#: data/gtk/details-window.blp:113
msgid "The developer or publisher (optional)"
msgstr ""
#: data/gtk/details-window.blp:123 data/gtk/details-window.blp:155
msgid "Executable" msgid "Executable"
msgstr "" msgstr ""
#: data/gtk/details-window.blp:116 #: 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
msgid "More Info" msgid "More Info"
msgstr "" msgstr ""
@@ -118,7 +128,7 @@ msgid "Quit"
msgstr "" msgstr ""
#: data/gtk/help-overlay.blp:19 data/gtk/window.blp:217 data/gtk/window.blp:257 #: data/gtk/help-overlay.blp:19 data/gtk/window.blp:217 data/gtk/window.blp:257
#: data/gtk/window.blp:324 #: data/gtk/window.blp:323
msgid "Search" msgid "Search"
msgstr "" msgstr ""
@@ -130,8 +140,7 @@ msgstr ""
msgid "Shortcuts" msgid "Shortcuts"
msgstr "" msgstr ""
#: data/gtk/help-overlay.blp:34 src/game.py:103 src/preferences.py:120 #: data/gtk/help-overlay.blp:34 src/game.py:102 src/preferences.py:113
#: src/importer/importer.py:366
msgid "Undo" msgid "Undo"
msgstr "" msgstr ""
@@ -159,8 +168,7 @@ msgstr ""
msgid "Remove game" msgid "Remove game"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:89 #: data/gtk/preferences.blp:13 data/gtk/preferences.blp:277
#: data/gtk/preferences.blp:304
msgid "Behavior" msgid "Behavior"
msgstr "" msgstr ""
@@ -196,114 +204,106 @@ msgstr ""
msgid "Remove All Games" msgid "Remove All Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:444 #: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:442
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:92 #: data/gtk/preferences.blp:89
msgid "Remove Uninstalled Games"
msgstr ""
#: data/gtk/preferences.blp:102
msgid "Sources" msgid "Sources"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:105 #: data/gtk/preferences.blp:92
msgid "Steam" msgid "Steam"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:109 data/gtk/preferences.blp:123 #: data/gtk/preferences.blp:96 data/gtk/preferences.blp:110
#: data/gtk/preferences.blp:164 data/gtk/preferences.blp:214 #: data/gtk/preferences.blp:151 data/gtk/preferences.blp:201
#: data/gtk/preferences.blp:228 data/gtk/preferences.blp:242 #: data/gtk/preferences.blp:215 data/gtk/preferences.blp:229
#: data/gtk/preferences.blp:256 data/gtk/preferences.blp:270 #: data/gtk/preferences.blp:243
msgid "Install Location" msgid "Install Location"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:119 #: data/gtk/preferences.blp:106
msgid "Lutris" msgid "Lutris"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:132 #: data/gtk/preferences.blp:119
msgid "Cache Location" msgid "Cache Location"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:141 #: data/gtk/preferences.blp:128
msgid "Import Steam Games" msgid "Import Steam Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:150 #: data/gtk/preferences.blp:137
msgid "Import Flatpak Games" msgid "Import Flatpak Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:160 #: data/gtk/preferences.blp:147
msgid "Heroic" msgid "Heroic"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:173 #: data/gtk/preferences.blp:160
msgid "Import Epic Games" msgid "Import Epic Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:182 #: data/gtk/preferences.blp:169
msgid "Import GOG Games" msgid "Import GOG Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:191 #: data/gtk/preferences.blp:178
msgid "Import Amazon Games" msgid "Import Amazon Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:200 #: data/gtk/preferences.blp:187
msgid "Import Sideloaded Games" msgid "Import Sideloaded Games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:210 #: data/gtk/preferences.blp:197
msgid "Bottles" msgid "Bottles"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:224 #: data/gtk/preferences.blp:211
msgid "itch" msgid "itch"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:238 #: data/gtk/preferences.blp:225
msgid "Legendary" msgid "Legendary"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:252 #: data/gtk/preferences.blp:239
msgid "RetroArch"
msgstr ""
#: data/gtk/preferences.blp:266
msgid "Flatpak" msgid "Flatpak"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:279 #: data/gtk/preferences.blp:252
msgid "Import Game Launchers" msgid "Import Game Launchers"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:292 #: data/gtk/preferences.blp:265
msgid "SteamGridDB" msgid "SteamGridDB"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:296 #: data/gtk/preferences.blp:269
msgid "Authentication" msgid "Authentication"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:299 #: data/gtk/preferences.blp:272
msgid "API Key" msgid "API Key"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:307 #: data/gtk/preferences.blp:280
msgid "Use SteamGridDB" msgid "Use SteamGridDB"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:308 #: data/gtk/preferences.blp:281
msgid "Download images when adding or importing games" msgid "Download images when adding or importing games"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:317 #: data/gtk/preferences.blp:290
msgid "Prefer Over Official Images" msgid "Prefer Over Official Images"
msgstr "" msgstr ""
#: data/gtk/preferences.blp:326 #: data/gtk/preferences.blp:299
msgid "Prefer Animated Images" msgid "Prefer Animated Images"
msgstr "" msgstr ""
@@ -331,7 +331,7 @@ msgstr ""
msgid "Games you hide will appear here." msgid "Games you hide will appear here."
msgstr "" msgstr ""
#: data/gtk/window.blp:64 data/gtk/window.blp:305 #: data/gtk/window.blp:64 data/gtk/window.blp:304
msgid "Back" msgid "Back"
msgstr "" msgstr ""
@@ -343,59 +343,51 @@ msgstr ""
msgid "Play" msgid "Play"
msgstr "" msgstr ""
#: data/gtk/window.blp:243 data/gtk/window.blp:437 #: data/gtk/window.blp:243 data/gtk/window.blp:435
msgid "Add Game" msgid "Add Game"
msgstr "" msgstr ""
#: data/gtk/window.blp:250 data/gtk/window.blp:317 #: data/gtk/window.blp:250 data/gtk/window.blp:316
msgid "Main Menu" msgid "Main Menu"
msgstr "" msgstr ""
#: data/gtk/window.blp:272 #: data/gtk/window.blp:311
msgid "Search games"
msgstr ""
#: data/gtk/window.blp:312
msgid "Hidden Games" msgid "Hidden Games"
msgstr "" msgstr ""
#: data/gtk/window.blp:339 #: data/gtk/window.blp:374
msgid "Search hidden games"
msgstr ""
#: data/gtk/window.blp:376
msgid "Sort" msgid "Sort"
msgstr "" msgstr ""
#: data/gtk/window.blp:379 #: data/gtk/window.blp:377
msgid "A-Z" msgid "A-Z"
msgstr "" msgstr ""
#: data/gtk/window.blp:385 #: data/gtk/window.blp:383
msgid "Z-A" msgid "Z-A"
msgstr "" msgstr ""
#: data/gtk/window.blp:391 #: data/gtk/window.blp:389
msgid "Newest" msgid "Newest"
msgstr "" msgstr ""
#: data/gtk/window.blp:397 #: data/gtk/window.blp:395
msgid "Oldest" msgid "Oldest"
msgstr "" msgstr ""
#: data/gtk/window.blp:403 #: data/gtk/window.blp:401
msgid "Last Played" msgid "Last Played"
msgstr "" msgstr ""
#: data/gtk/window.blp:410 #: data/gtk/window.blp:408
msgid "Show Hidden" msgid "Show Hidden"
msgstr "" msgstr ""
#: data/gtk/window.blp:423 #: data/gtk/window.blp:421
msgid "Keyboard Shortcuts" msgid "Keyboard Shortcuts"
msgstr "" msgstr ""
#: data/gtk/window.blp:428 #: data/gtk/window.blp:426
msgid "About Cartridges" msgid "About Cartridges"
msgstr "" msgstr ""
@@ -427,7 +419,7 @@ msgid "Add New Game"
msgstr "" msgstr ""
#: src/details_window.py:79 #: src/details_window.py:79
msgid "Add" msgid "Confirm"
msgstr "" msgstr ""
#. Translate this string as you would translate "file" #. Translate this string as you would translate "file"
@@ -480,103 +472,71 @@ msgid "Couldn't Apply Preferences"
msgstr "" msgstr ""
#. The variable is the title of the game #. The variable is the title of the game
#: src/game.py:139 #: src/game.py:138
msgid "{} launched" msgid "{} launched"
msgstr "" msgstr ""
#. The variable is the title of the game #. The variable is the title of the game
#: src/game.py:153 #: src/game.py:152
msgid "{} hidden" msgid "{} hidden"
msgstr "" msgstr ""
#: src/game.py:153 #: src/game.py:152
msgid "{} unhidden" msgid "{} unhidden"
msgstr "" msgstr ""
#. The variable is the title of the game #: src/game.py:169
#. The variable is the number of games removed
#: src/game.py:170 src/importer/importer.py:363
msgid "{} removed" msgid "{} removed"
msgstr "" msgstr ""
#: src/preferences.py:119 #: src/preferences.py:112
msgid "All games removed" msgid "All games removed"
msgstr "" msgstr ""
#: src/preferences.py:168 #: src/preferences.py:160
msgid "" msgid ""
"An API key is required to use SteamGridDB. You can generate one {}here{}." "An API key is required to use SteamGridDB. You can generate one {}here{}."
msgstr "" msgstr ""
#: src/preferences.py:294 #: src/preferences.py:285
msgid "Installation Not Found" msgid "Installation Not Found"
msgstr "" msgstr ""
#: src/preferences.py:296 #: src/preferences.py:287
msgid "Select a valid directory." msgid "Select a valid directory."
msgstr "" msgstr ""
#: src/preferences.py:351 #: src/preferences.py:349
msgid "Invalid Directory" msgid "Invalid Directory"
msgstr "" msgstr ""
#: src/preferences.py:357
msgid "Set Location"
msgstr ""
#: src/utils/create_dialog.py:25 src/importer/importer.py:291
msgid "Dismiss"
msgstr ""
#: src/importer/importer.py:128
msgid "Importing Games…"
msgstr ""
#: src/importer/importer.py:290
msgid "Warning"
msgstr ""
#: src/importer/importer.py:311
msgid "The following errors occured during import:"
msgstr ""
#: src/importer/importer.py:339
msgid "No new games found"
msgstr ""
#: src/importer/importer.py:351
msgid "1 game imported"
msgstr ""
#. The variable is the number of games
#: src/importer/importer.py:355
msgid "{} games imported"
msgstr ""
#. A single game removed
#: src/importer/importer.py:359
msgid "1 removed"
msgstr ""
#. The variable is the name of the source #. The variable is the name of the source
#: src/importer/sources/location.py:33 #: src/preferences.py:353
msgid "Select the {} cache directory." msgid "Select the {} cache directory."
msgstr "" msgstr ""
#. The variable is the name of the source #. The variable is the name of the source
#: src/importer/sources/location.py:35 #: src/preferences.py:356
msgid "Select the {} configuration directory." msgid "Select the {} configuration directory."
msgstr "" msgstr ""
#. The variable is the name of the source #. The variable is the name of the source
#: src/importer/sources/location.py:37 #: src/preferences.py:359
msgid "Select the {} data directory." msgid "Select the {} data directory."
msgstr "" msgstr ""
#: src/store/managers/sgdb_manager.py:46 #: src/preferences.py:365
msgid "Couldn't Authenticate SteamGridDB" msgid "Set Location"
msgstr ""
#: src/utils/create_dialog.py:25
msgid "Dismiss"
msgstr "" msgstr ""
#: src/store/managers/sgdb_manager.py:47 #: 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" msgid "Verify your API key in preferences"
msgstr "" msgstr ""

View File

@@ -19,7 +19,6 @@
import os import os
from time import time from time import time
from typing import Any, Optional
from gi.repository import Adw, Gio, GLib, Gtk from gi.repository import Adw, Gio, GLib, Gtk
from PIL import Image from PIL import Image
@@ -53,18 +52,19 @@ class DetailsWindow(Adw.Window):
apply_button = Gtk.Template.Child() apply_button = Gtk.Template.Child()
cover_changed: bool = False cover_changed = False
def __init__(self, game: Optional[Game] = None, **kwargs: Any): def __init__(self, game=None, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.game: Game = game self.win = shared.win
self.game_cover: GameCover = GameCover({self.cover}) self.game = game
self.game_cover = GameCover({self.cover})
self.set_transient_for(shared.win) self.set_transient_for(self.win)
if self.game: if self.game:
self.set_title(_("Game Details")) self.set_title(_("Edit Game Details"))
self.name.set_text(self.game.name) self.name.set_text(self.game.name)
if self.game.developer: if self.game.developer:
self.developer.set_text(self.game.developer) self.developer.set_text(self.game.developer)
@@ -76,7 +76,7 @@ class DetailsWindow(Adw.Window):
self.cover_button_delete_revealer.set_reveal_child(True) self.cover_button_delete_revealer.set_reveal_child(True)
else: else:
self.set_title(_("Add New Game")) self.set_title(_("Add New Game"))
self.apply_button.set_label(_("Add")) self.apply_button.set_label(_("Confirm"))
image_filter = Gtk.FileFilter(name=_("Images")) image_filter = Gtk.FileFilter(name=_("Images"))
for extension in Image.registered_extensions(): for extension in Image.registered_extensions():
@@ -114,36 +114,29 @@ class DetailsWindow(Adw.Window):
self.exec_info_label.set_label(exec_info_text) self.exec_info_label.set_label(exec_info_text)
self.exec_info_popover.update_property( def clear_info_selection(*_args):
(Gtk.AccessibleProperty.LABEL,), self.exec_info_label.select_region(-1, -1)
(
exec_info_text.replace("<tt>", "").replace("</tt>", ""),
), # Remove formatting, else the screen reader reads it
)
def set_exec_info_a11y_label(*_args: Any) -> None: self.exec_info_popover.connect("show", clear_info_selection)
self.set_focus(self.exec_info_popover)
self.exec_info_popover.connect("show", set_exec_info_a11y_label)
self.cover_button_delete.connect("clicked", self.delete_pixbuf) self.cover_button_delete.connect("clicked", self.delete_pixbuf)
self.cover_button_edit.connect("clicked", self.choose_cover) self.cover_button_edit.connect("clicked", self.choose_cover)
self.apply_button.connect("clicked", self.apply_preferences) self.apply_button.connect("clicked", self.apply_preferences)
self.name.connect("entry-activated", self.focus_executable) self.name.connect("activate", self.focus_executable)
self.developer.connect("entry-activated", self.focus_executable) self.developer.connect("activate", self.focus_executable)
self.executable.connect("entry-activated", self.apply_preferences) self.executable.connect("activate", self.apply_preferences)
self.set_focus(self.name) self.set_focus(self.name)
self.present() self.present()
def delete_pixbuf(self, *_args: Any) -> None: def delete_pixbuf(self, *_args):
self.game_cover.new_cover() self.game_cover.new_cover()
self.cover_button_delete_revealer.set_reveal_child(False) self.cover_button_delete_revealer.set_reveal_child(False)
self.cover_changed = True self.cover_changed = True
def apply_preferences(self, *_args: Any) -> None: def apply_preferences(self, *_args):
final_name = self.name.get_text() final_name = self.name.get_text()
final_developer = self.developer.get_text() final_developer = self.developer.get_text()
final_executable = self.executable.get_text() final_executable = self.executable.get_text()
@@ -203,10 +196,10 @@ class DetailsWindow(Adw.Window):
self.game.developer = final_developer or None self.game.developer = final_developer or None
self.game.executable = final_executable self.game.executable = final_executable
if self.game.game_id in shared.win.game_covers.keys(): if self.game.game_id in self.win.game_covers.keys():
shared.win.game_covers[self.game.game_id].animation = None self.win.game_covers[self.game.game_id].animation = None
shared.win.game_covers[self.game.game_id] = self.game_cover self.win.game_covers[self.game.game_id] = self.game_cover
if self.cover_changed: if self.cover_changed:
save_cover( save_cover(
@@ -229,9 +222,9 @@ class DetailsWindow(Adw.Window):
self.game_cover.pictures.remove(self.cover) self.game_cover.pictures.remove(self.cover)
self.close() self.close()
shared.win.show_details_view(self.game) self.win.show_details_view(self.game)
def update_cover_callback(self, manager: SGDBManager) -> None: def update_cover_callback(self, manager: SGDBManager):
# Set the game as not loading # Set the game as not loading
self.game.set_loading(-1) self.game.set_loading(-1)
self.game.update() self.game.update()
@@ -248,25 +241,25 @@ class DetailsWindow(Adw.Window):
_("Preferences"), _("Preferences"),
).connect("response", self.update_cover_error_response) ).connect("response", self.update_cover_error_response)
def update_cover_error_response(self, _widget: Any, response: str) -> None: def update_cover_error_response(self, _widget, response):
if response == "open_preferences": if response == "open_preferences":
shared.win.get_application().on_preferences_action(page_name="sgdb") shared.win.get_application().on_preferences_action(page_name="sgdb")
def focus_executable(self, *_args: Any) -> None: def focus_executable(self, *_args):
self.set_focus(self.executable) self.set_focus(self.executable)
def toggle_loading(self) -> None: def toggle_loading(self):
self.apply_button.set_sensitive(not self.apply_button.get_sensitive()) self.apply_button.set_sensitive(not self.apply_button.get_sensitive())
self.spinner.set_spinning(not self.spinner.get_spinning()) self.spinner.set_spinning(not self.spinner.get_spinning())
self.cover_overlay.set_opacity(not self.cover_overlay.get_opacity()) self.cover_overlay.set_opacity(not self.cover_overlay.get_opacity())
def set_cover(self, _source: Any, result: Gio.Task, *_args: Any) -> None: def set_cover(self, _source, result, *_args):
try: try:
path = self.file_dialog.open_finish(result).get_path() path = self.file_dialog.open_finish(result).get_path()
except GLib.GError: except GLib.GError:
return return
def resize() -> None: def resize():
if cover := resize_cover(path): if cover := resize_cover(path):
self.game_cover.new_cover(cover) self.game_cover.new_cover(cover)
self.cover_button_delete_revealer.set_reveal_child(True) self.cover_button_delete_revealer.set_reveal_child(True)
@@ -276,5 +269,5 @@ class DetailsWindow(Adw.Window):
self.toggle_loading() self.toggle_loading()
GLib.Thread.new(None, resize) GLib.Thread.new(None, resize)
def choose_cover(self, *_args: Any) -> None: def choose_cover(self, *_args):
self.file_dialog.open(self, None, self.set_cover) self.file_dialog.open(self, None, self.set_cover)

View File

@@ -8,14 +8,14 @@ class ErrorProducer:
Specifies the report_error and collect_errors methods in a thread-safe manner. Specifies the report_error and collect_errors methods in a thread-safe manner.
""" """
errors: list[Exception] errors: list[Exception] = None
errors_lock: Lock errors_lock: Lock = None
def __init__(self) -> None: def __init__(self) -> None:
self.errors = [] self.errors = []
self.errors_lock = Lock() self.errors_lock = Lock()
def report_error(self, error: Exception) -> None: def report_error(self, error: Exception):
"""Report an error""" """Report an error"""
with self.errors_lock: with self.errors_lock:
self.errors.append(error) self.errors.append(error)

View File

@@ -1,4 +1,4 @@
from typing import Iterable, Optional from typing import Iterable
class FriendlyError(Exception): class FriendlyError(Exception):
@@ -27,8 +27,8 @@ class FriendlyError(Exception):
self, self,
title: str, title: str,
subtitle: str, subtitle: str,
title_args: Optional[Iterable[str]] = None, title_args: Iterable[str] = None,
subtitle_args: Optional[Iterable[str]] = None, subtitle_args: Iterable[str] = None,
) -> None: ) -> None:
"""Create a friendly error """Create a friendly error

View File

@@ -23,12 +23,10 @@ import shlex
import subprocess import subprocess
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Any, Optional
from gi.repository import Adw, GLib, GObject, Gtk from gi.repository import Adw, GLib, GObject, Gtk
from src import shared from src import shared
from src.game_cover import GameCover
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
@@ -47,23 +45,23 @@ class Game(Gtk.Box):
game_options = Gtk.Template.Child() game_options = Gtk.Template.Child()
hidden_game_options = Gtk.Template.Child() hidden_game_options = Gtk.Template.Child()
loading: int = 0 loading = 0
filtered: bool = False filtered = False
added: int added = None
executable: str executable = None
game_id: str game_id = None
source: str source = None
hidden: bool = False hidden = False
last_played: int = 0 last_played = 0
name: str name = None
developer: Optional[str] = None developer = None
removed: bool = False removed = False
blacklisted: bool = False blacklisted = False
game_cover: GameCover = None game_cover = None
version: int = 0 version = 0
def __init__(self, data: dict[str, Any], **kwargs: Any) -> None: def __init__(self, data, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.win = shared.win
@@ -71,7 +69,6 @@ class Game(Gtk.Box):
self.version = shared.SPEC_VERSION self.version = shared.SPEC_VERSION
self.update_values(data) self.update_values(data)
self.base_source = self.source.split("_")[0]
self.set_play_icon() self.set_play_icon()
@@ -84,20 +81,20 @@ class Game(Gtk.Box):
shared.schema.connect("changed", self.schema_changed) shared.schema.connect("changed", self.schema_changed)
def update_values(self, data: dict[str, Any]) -> None: def update_values(self, data):
for key, value in data.items(): for key, value in data.items():
# Convert executables to strings # Convert executables to strings
if key == "executable" and isinstance(value, list): if key == "executable" and isinstance(value, list):
value = shlex.join(value) value = shlex.join(value)
setattr(self, key, value) setattr(self, key, value)
def update(self) -> None: def update(self):
self.emit("update-ready", {}) self.emit("update-ready", {})
def save(self) -> None: def save(self):
self.emit("save-ready", {}) self.emit("save-ready", {})
def create_toast(self, title: str, action: Optional[str] = None) -> None: def create_toast(self, title, action=None):
toast = Adw.Toast.new(title.format(self.name)) toast = Adw.Toast.new(title.format(self.name))
toast.set_priority(Adw.ToastPriority.HIGH) toast.set_priority(Adw.ToastPriority.HIGH)
@@ -113,7 +110,7 @@ class Game(Gtk.Box):
self.win.toast_overlay.add_toast(toast) self.win.toast_overlay.add_toast(toast)
def launch(self) -> None: def launch(self):
self.last_played = int(time()) self.last_played = int(time())
self.save() self.save()
self.update() self.update()
@@ -128,10 +125,10 @@ class Game(Gtk.Box):
# pylint: disable=consider-using-with # pylint: disable=consider-using-with
subprocess.Popen( subprocess.Popen(
args, args,
cwd=shared.home, cwd=Path.home(),
shell=True, shell=True,
start_new_session=True, start_new_session=True,
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0,
) )
if shared.schema.get_boolean("exit-after-launch"): if shared.schema.get_boolean("exit-after-launch"):
@@ -140,7 +137,7 @@ class Game(Gtk.Box):
# The variable is the title of the game # The variable is the title of the game
self.create_toast(_("{} launched")) self.create_toast(_("{} launched"))
def toggle_hidden(self, toast: bool = True) -> None: def toggle_hidden(self, toast=True):
self.hidden = not self.hidden self.hidden = not self.hidden
self.save() self.save()
@@ -158,7 +155,7 @@ class Game(Gtk.Box):
"hide", "hide",
) )
def remove_game(self) -> None: def remove_game(self):
# Add "removed=True" to the game properties so it can be deleted on next init # Add "removed=True" to the game properties so it can be deleted on next init
self.removed = True self.removed = True
self.save() self.save()
@@ -167,58 +164,55 @@ class Game(Gtk.Box):
if self.win.stack.get_visible_child() == self.win.details_view: if self.win.stack.get_visible_child() == self.win.details_view:
self.win.on_go_back_action() self.win.on_go_back_action()
# The variable is the title of the game
self.create_toast( self.create_toast(
# The variable is the title of the game _("{} removed").format(GLib.markup_escape_text(self.name)), "remove"
_("{} removed").format(GLib.markup_escape_text(self.name)),
"remove",
) )
def set_loading(self, state: int) -> None: def set_loading(self, state):
self.loading += state self.loading += state
loading = self.loading > 0 loading = self.loading > 0
self.cover.set_opacity(int(not loading)) self.cover.set_opacity(int(not loading))
self.spinner.set_spinning(loading) self.spinner.set_spinning(loading)
def get_cover_path(self) -> Optional[Path]: def get_cover_path(self):
cover_path = shared.covers_dir / f"{self.game_id}.gif" cover_path = shared.covers_dir / f"{self.game_id}.gif"
if cover_path.is_file(): if cover_path.is_file():
return cover_path # type: ignore return cover_path
cover_path = shared.covers_dir / f"{self.game_id}.tiff" cover_path = shared.covers_dir / f"{self.game_id}.tiff"
if cover_path.is_file(): if cover_path.is_file():
return cover_path # type: ignore return cover_path
return None return None
def toggle_play( def toggle_play(self, _widget, _prop1, _prop2, state=True):
self, _widget: Any, _prop1: Any, _prop2: Any, state: bool = True
) -> None:
if not self.menu_button.get_active(): if not self.menu_button.get_active():
self.play_revealer.set_reveal_child(not state) self.play_revealer.set_reveal_child(not state)
self.menu_revealer.set_reveal_child(not state) self.menu_revealer.set_reveal_child(not state)
def main_button_clicked(self, _widget: Any, button: bool) -> None: def main_button_clicked(self, _widget, button):
if shared.schema.get_boolean("cover-launches-game") ^ button: if shared.schema.get_boolean("cover-launches-game") ^ button:
self.launch() self.launch()
else: else:
self.win.show_details_view(self) self.win.show_details_view(self)
def set_play_icon(self) -> None: def set_play_icon(self):
self.play_button.set_icon_name( self.play_button.set_icon_name(
"help-about-symbolic" "help-about-symbolic"
if shared.schema.get_boolean("cover-launches-game") if shared.schema.get_boolean("cover-launches-game")
else "media-playback-start-symbolic" else "media-playback-start-symbolic"
) )
def schema_changed(self, _settings: Any, key: str) -> None: def schema_changed(self, _settings, key):
if key == "cover-launches-game": if key == "cover-launches-game":
self.set_play_icon() self.set_play_icon()
@GObject.Signal(name="update-ready", arg_types=[object]) @GObject.Signal(name="update-ready", arg_types=[object])
def update_ready(self, _additional_data): # type: ignore def update_ready(self, _additional_data) -> None:
"""Signal emitted when the game needs updating""" """Signal emitted when the game needs updating"""
@GObject.Signal(name="save-ready", arg_types=[object]) @GObject.Signal(name="save-ready", arg_types=[object])
def save_ready(self, _additional_data): # type: ignore def save_ready(self, _additional_data) -> None:
"""Signal emitted when the game needs saving""" """Signal emitted when the game needs saving"""

View File

@@ -17,22 +17,19 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path from gi.repository import Gdk, GdkPixbuf, Gio, GLib
from typing import Any, Callable, Optional
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
from PIL import Image, ImageFilter, ImageStat from PIL import Image, ImageFilter, ImageStat
from src import shared from src import shared
class GameCover: class GameCover:
texture: Optional[Gdk.Texture] texture = None
blurred: Optional[Gdk.Texture] blurred = None
luminance: Optional[tuple[float, float]] luminance = None
path: Optional[Path] path = None
animation: Optional[GdkPixbuf.PixbufAnimation] animation = None
anim_iter: Optional[GdkPixbuf.PixbufAnimationIter] anim_iter = None
placeholder = Gdk.Texture.new_from_resource( placeholder = Gdk.Texture.new_from_resource(
shared.PREFIX + "/library_placeholder.svg" shared.PREFIX + "/library_placeholder.svg"
@@ -41,21 +38,21 @@ class GameCover:
shared.PREFIX + "/library_placeholder_small.svg" shared.PREFIX + "/library_placeholder_small.svg"
) )
def __init__(self, pictures: set[Gtk.Picture], path: Optional[Path] = None) -> None: def __init__(self, pictures, path=None):
self.pictures = pictures self.pictures = pictures
self.new_cover(path) self.new_cover(path)
# Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args # Wrap the function in another one as Gio.Task.run_in_thread does not allow for passing args
def create_func(self, path: Optional[Path]) -> Callable: def create_func(self, path):
self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path)) self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path))
self.anim_iter = self.animation.get_iter() self.anim_iter = self.animation.get_iter()
def wrapper(task: Gio.Task, *_args: Any) -> None: def wrapper(task, *_args):
self.update_animation((task, self.animation)) self.update_animation((task, self.animation))
return wrapper return wrapper
def new_cover(self, path: Optional[Path] = None) -> None: def new_cover(self, path=None):
self.animation = None self.animation = None
self.texture = None self.texture = None
self.blurred = None self.blurred = None
@@ -72,14 +69,14 @@ class GameCover:
if not self.animation: if not self.animation:
self.set_texture(self.texture) self.set_texture(self.texture)
def get_texture(self) -> Gdk.Texture: def get_texture(self):
return ( return (
Gdk.Texture.new_for_pixbuf(self.animation.get_static_image()) Gdk.Texture.new_for_pixbuf(self.animation.get_static_image())
if self.animation if self.animation
else self.texture else self.texture
) )
def get_blurred(self) -> Gdk.Texture: def get_blurred(self):
if not self.blurred: if not self.blurred:
if self.path: if self.path:
with Image.open(self.path) as image: with Image.open(self.path) as image:
@@ -97,24 +94,24 @@ class GameCover:
stat = ImageStat.Stat(image.convert("L")) stat = ImageStat.Stat(image.convert("L"))
# Luminance values for light and dark mode # Luminance values for light and dark mode
self.luminance = ( self.luminance = [
min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7), min((stat.mean[0] + stat.extrema[0][0]) / 510, 0.7),
max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3), max((stat.mean[0] + stat.extrema[0][1]) / 510, 0.3),
) ]
else: else:
self.blurred = self.placeholder_small self.blurred = self.placeholder_small
self.luminance = (0.3, 0.5) self.luminance = (0.3, 0.5)
return self.blurred return self.blurred
def add_picture(self, picture: Gtk.Picture) -> None: def add_picture(self, picture):
self.pictures.add(picture) self.pictures.add(picture)
if not self.animation: if not self.animation:
self.set_texture(self.texture) self.set_texture(self.texture)
else: else:
self.update_animation((self.task, self.animation)) self.update_animation((self.task, self.animation))
def set_texture(self, texture: Gdk.Texture) -> None: def set_texture(self, texture):
self.pictures.discard( self.pictures.discard(
picture for picture in self.pictures if not picture.is_visible() picture for picture in self.pictures if not picture.is_visible()
) )
@@ -124,13 +121,13 @@ class GameCover:
for picture in self.pictures: for picture in self.pictures:
picture.set_paintable(texture or self.placeholder) picture.set_paintable(texture or self.placeholder)
def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None: def update_animation(self, data):
if self.animation == data[1]: if self.animation == data[1]:
self.anim_iter.advance() # type: ignore self.anim_iter.advance()
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf()))
delay_time = self.anim_iter.get_delay_time() # type: ignore delay_time = self.anim_iter.get_delay_time()
GLib.timeout_add( GLib.timeout_add(
20 if delay_time < 20 else delay_time, 20 if delay_time < 20 else delay_time,
self.update_animation, self.update_animation,

View File

@@ -19,7 +19,6 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import logging import logging
from typing import Any, Optional
from gi.repository import Adw, GLib, Gtk from gi.repository import Adw, GLib, Gtk
@@ -38,49 +37,41 @@ from src.utils.task import Task
class Importer(ErrorProducer): class Importer(ErrorProducer):
"""A class in charge of scanning sources for games""" """A class in charge of scanning sources for games"""
progressbar: Gtk.ProgressBar progressbar = None
import_statuspage: Adw.StatusPage import_statuspage = None
import_dialog: Adw.MessageDialog import_dialog = None
summary_toast: Adw.Toast summary_toast = None
sources: set[Source] sources: set[Source] = None
n_source_tasks_created: int = 0 n_source_tasks_created: int = 0
n_source_tasks_done: int = 0 n_source_tasks_done: int = 0
n_pipelines_done: int = 0 n_pipelines_done: int = 0
game_pipelines: set[Pipeline] game_pipelines: set[Pipeline] = None
removed_game_ids: set[str] = set() def __init__(self):
imported_game_ids: set[str] = set()
def __init__(self) -> None:
super().__init__() super().__init__()
# TODO: make this stateful
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.game_pipelines = set() self.game_pipelines = set()
self.sources = set() self.sources = set()
@property @property
def n_games_added(self) -> int: def n_games_added(self):
return sum( return sum(
1 if not (pipeline.game.blacklisted or pipeline.game.removed) else 0 1 if not (pipeline.game.blacklisted or pipeline.game.removed) else 0
for pipeline in self.game_pipelines for pipeline in self.game_pipelines
) )
@property @property
def pipelines_progress(self) -> float: def pipelines_progress(self):
progress = sum(pipeline.progress for pipeline in self.game_pipelines) progress = sum(pipeline.progress for pipeline in self.game_pipelines)
try: try:
progress = progress / len(self.game_pipelines) progress = progress / len(self.game_pipelines)
except ZeroDivisionError: except ZeroDivisionError:
progress = 0 progress = 0
return progress # type: ignore return progress
@property @property
def sources_progress(self) -> float: def sources_progress(self):
try: try:
progress = self.n_source_tasks_done / self.n_source_tasks_created progress = self.n_source_tasks_done / self.n_source_tasks_created
except ZeroDivisionError: except ZeroDivisionError:
@@ -88,16 +79,16 @@ class Importer(ErrorProducer):
return progress return progress
@property @property
def finished(self) -> bool: def finished(self):
return ( return (
self.n_source_tasks_created == self.n_source_tasks_done self.n_source_tasks_created == self.n_source_tasks_done
and len(self.game_pipelines) == self.n_pipelines_done and len(self.game_pipelines) == self.n_pipelines_done
) )
def add_source(self, source: Source) -> None: def add_source(self, source):
self.sources.add(source) self.sources.add(source)
def run(self) -> None: def run(self):
"""Use several Gio.Task to import games from added sources""" """Use several Gio.Task to import games from added sources"""
shared.win.get_application().lookup_action("import").set_enabled(False) shared.win.get_application().lookup_action("import").set_enabled(False)
@@ -122,7 +113,7 @@ class Importer(ErrorProducer):
self.progress_changed_callback() self.progress_changed_callback()
def create_dialog(self) -> None: def create_dialog(self):
"""Create the import dialog""" """Create the import dialog"""
self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12) self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12)
self.import_statuspage = Adw.StatusPage( self.import_statuspage = Adw.StatusPage(
@@ -139,9 +130,7 @@ class Importer(ErrorProducer):
) )
self.import_dialog.present() self.import_dialog.present()
def source_task_thread_func( def source_task_thread_func(self, _task, _obj, data, _cancellable):
self, _task: Any, _obj: Any, data: tuple, _cancellable: Any
) -> None:
"""Source import task code""" """Source import task code"""
source: Source source: Source
@@ -195,27 +184,27 @@ class Importer(ErrorProducer):
pipeline.connect("advanced", self.pipeline_advanced_callback) pipeline.connect("advanced", self.pipeline_advanced_callback)
self.game_pipelines.add(pipeline) self.game_pipelines.add(pipeline)
def update_progressbar(self) -> None: def update_progressbar(self):
"""Update the progressbar to show the overall import progress""" """Update the progressbar to show the overall import progress"""
# Reserve 10% for the sources discovery, the rest is the pipelines # Reserve 10% for the sources discovery, the rest is the pipelines
self.progressbar.set_fraction( self.progressbar.set_fraction(
(0.1 * self.sources_progress) + (0.9 * self.pipelines_progress) (0.1 * self.sources_progress) + (0.9 * self.pipelines_progress)
) )
def source_callback(self, _obj: Any, _result: Any, data: tuple) -> None: def source_callback(self, _obj, _result, data):
"""Callback executed when a source is fully scanned""" """Callback executed when a source is fully scanned"""
source, *_rest = data source, *_rest = data
logging.debug("Import done for source %s", source.source_id) logging.debug("Import done for source %s", source.source_id)
self.n_source_tasks_done += 1 self.n_source_tasks_done += 1
self.progress_changed_callback() self.progress_changed_callback()
def pipeline_advanced_callback(self, pipeline: Pipeline) -> None: def pipeline_advanced_callback(self, pipeline: Pipeline):
"""Callback called when a pipeline for a game has advanced""" """Callback called when a pipeline for a game has advanced"""
if pipeline.is_done: if pipeline.is_done:
self.n_pipelines_done += 1 self.n_pipelines_done += 1
self.progress_changed_callback() self.progress_changed_callback()
def progress_changed_callback(self) -> None: def progress_changed_callback(self):
""" """
Callback called when the import process has progressed Callback called when the import process has progressed
@@ -228,47 +217,19 @@ class Importer(ErrorProducer):
if self.finished: if self.finished:
self.import_callback() self.import_callback()
def remove_games(self) -> None: def import_callback(self):
"""Set removed to True for missing games"""
if not shared.schema.get_boolean("remove-missing"):
return
for game in shared.store:
if game.removed:
continue
if game.source == "imported":
continue
if not shared.schema.get_boolean(game.base_source):
continue
if game.game_id in shared.store.duplicate_game_ids:
continue
if game.game_id in shared.store.new_game_ids:
continue
logging.debug("Removing missing game %s (%s)", game.name, game.game_id)
game.removed = True
game.save()
game.update()
self.removed_game_ids.add(game.game_id)
def import_callback(self) -> None:
"""Callback called when importing has finished""" """Callback called when importing has finished"""
logging.info("Import done") logging.info("Import done")
self.remove_games()
self.imported_game_ids = shared.store.new_game_ids
shared.store.new_game_ids = set()
shared.store.duplicate_game_ids = set()
self.import_dialog.close() self.import_dialog.close()
self.summary_toast = self.create_summary_toast() self.summary_toast = self.create_summary_toast()
self.create_error_dialog() self.create_error_dialog()
shared.win.get_application().lookup_action("import").set_enabled(True) shared.win.get_application().lookup_action("import").set_enabled(True)
def create_error_dialog(self) -> None: def create_error_dialog(self):
"""Dialog containing all errors raised by importers""" """Dialog containing all errors raised by importers"""
# Collect all errors that happened in the importer and the managers # Collect all errors that happened in the importer and the managers
errors = [] errors: list[Exception] = []
errors.extend(self.collect_errors()) errors.extend(self.collect_errors())
for manager in shared.store.managers.values(): for manager in shared.store.managers.values():
errors.extend(manager.collect_errors()) errors.extend(manager.collect_errors())
@@ -316,78 +277,41 @@ class Importer(ErrorProducer):
dialog.present() dialog.present()
def undo_import(self, *_args: Any) -> None: def create_summary_toast(self):
for game_id in self.imported_game_ids: """N games imported toast"""
shared.store[game_id].removed = True
shared.store[game_id].update()
shared.store[game_id].save()
for game_id in self.removed_game_ids:
shared.store[game_id].removed = False
shared.store[game_id].update()
shared.store[game_id].save()
self.imported_game_ids = set()
self.removed_game_ids = set()
self.summary_toast.dismiss()
logging.info("Import undone")
def create_summary_toast(self) -> Adw.Toast:
"""N games imported, removed toast"""
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH) toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
if not self.n_games_added: if self.n_games_added == 0:
toast_title = _("No new games found") toast.set_title(_("No new games found"))
toast.set_button_label(_("Preferences"))
if not self.removed_game_ids: toast.connect(
toast.set_button_label(_("Preferences")) "button-clicked",
toast.connect( self.dialog_response_callback,
"button-clicked", "open_preferences",
self.dialog_response_callback, "import",
"open_preferences", )
"import",
)
elif self.n_games_added == 1: elif self.n_games_added == 1:
toast_title = _("1 game imported") toast.set_title(_("1 game imported"))
elif self.n_games_added > 1: elif self.n_games_added > 1:
# The variable is the number of games # The variable is the number of games
toast_title = _("{} games imported").format(self.n_games_added) toast.set_title(_("{} games imported").format(self.n_games_added))
if (removed_length := len(self.removed_game_ids)) == 1:
# A single game removed
toast_title += ", " + _("1 removed")
elif removed_length > 1:
# The variable is the number of games removed
toast_title += ", " + _("{} removed").format(removed_length)
if self.n_games_added or self.removed_game_ids:
toast.set_button_label(_("Undo"))
toast.connect("button-clicked", self.undo_import)
toast.set_title(toast_title)
shared.win.toast_overlay.add_toast(toast) shared.win.toast_overlay.add_toast(toast)
return toast return toast
def open_preferences( def open_preferences(self, page=None, expander_row=None):
self,
page_name: Optional[str] = None,
expander_row: Optional[Adw.ExpanderRow] = None,
) -> Adw.PreferencesWindow:
return shared.win.get_application().on_preferences_action( return shared.win.get_application().on_preferences_action(
page_name=page_name, expander_row=expander_row page_name=page, expander_row=expander_row
) )
def timeout_toast(self, *_args: Any) -> None: def timeout_toast(self, *_args):
"""Manually timeout the toast after the user has dismissed all warnings""" """Manually timeout the toast after the user has dismissed all warnings"""
GLib.timeout_add_seconds(5, self.summary_toast.dismiss) GLib.timeout_add_seconds(5, self.summary_toast.dismiss)
def dialog_response_callback(self, _widget: Any, response: str, *args: Any) -> None: def dialog_response_callback(self, _widget, response, *args):
"""Handle after-import dialogs callback""" """Handle after-import dialogs callback"""
logging.debug("After-import dialog response: %s (%s)", response, str(args)) logging.debug("After-import dialog response: %s (%s)", response, str(args))
if response == "open_preferences": if response == "open_preferences":

View File

@@ -90,22 +90,18 @@ class BottlesSource(URLExecutableSource):
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"' url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
available_on = {"linux"} available_on = {"linux"}
locations: BottlesLocations locations = BottlesLocations(
Location(
def __init__(self) -> None: schema_key="bottles-location",
super().__init__() candidates=(
self.locations = BottlesLocations( shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
Location( shared.data_dir / "bottles/",
schema_key="bottles-location", shared.home / ".local" / "share" / "bottles",
candidates=( ),
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles", paths={
shared.data_dir / "bottles/", "library.yml": LocationSubPath("library.yml"),
shared.home / ".local" / "share" / "bottles", "data.yml": LocationSubPath("data.yml"),
), },
paths={ invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
"library.yml": LocationSubPath("library.yml"),
"data.yml": LocationSubPath("data.yml"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -0,0 +1,98 @@
# dolphin_source.py
#
# Copyright 2023 Rilic
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
from pathlib import Path
from time import time
from typing import NamedTuple
from src import shared
from src.game import Game
from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import Source, SourceIterable
from src.utils.dolphin_cache_reader import DolphinCacheReader
class DolphinSourceIterable(SourceIterable):
source: "DolphinSource"
def __iter__(self):
added_time = int(time())
cache_reader = DolphinCacheReader(self.source.locations.cache["cache_file"])
games_data = cache_reader.get_games()
for game_data in games_data:
# Build game
values = {
"source": self.source.source_id,
"added": added_time,
"name": Path(game_data["file_name"]).stem,
"game_id": self.source.game_id_format.format(
game_id=game_data["game_id"]
),
"executable": self.source.executable_format.format(
rom_path=game_data["file_path"],
),
}
game = Game(values)
image_path = Path(
self.source.locations.cache["covers"] / (game_data["game_id"] + ".png")
)
additional_data = {"local_image_path": image_path}
yield (game, additional_data)
class DolphinLocations(NamedTuple):
cache: Location
class DolphinSource(Source):
name = _("Dolphin")
source_id = "dolphin"
available_on = {"linux"}
iterable_class = DolphinSourceIterable
locations = DolphinLocations(
Location(
schema_key="dolphin-cache-location",
candidates=[
shared.flatpak_dir
/ "org.DolphinEmu.dolphin-emu"
/ "cache"
/ "dolphin-emu",
shared.home / ".cache" / "dolphin-emu",
],
paths={
"cache_file": LocationSubPath("gamelist.cache"),
"covers": LocationSubPath("GameCovers", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
)
)
@property
def executable_format(self):
self.locations.cache.resolve()
is_flatpak = self.locations.cache.root.is_relative_to(shared.flatpak_dir)
base = "flatpak run org.DolphinEmu.dolphin-emu" if is_flatpak else "dolphin-emu"
args = '-b -e "{rom_path}"'
return f"{base} {args}"

View File

@@ -26,7 +26,7 @@ from gi.repository import GLib, Gtk
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.location import Location, LocationSubPath from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import ExecutableFormatSource, SourceIterable from src.importer.sources.source import Source, SourceIterable
class FlatpakSourceIterable(SourceIterable): class FlatpakSourceIterable(SourceIterable):
@@ -116,7 +116,7 @@ class FlatpakLocations(NamedTuple):
data: Location data: Location
class FlatpakSource(ExecutableFormatSource): class FlatpakSource(Source):
"""Generic Flatpak source""" """Generic Flatpak source"""
source_id = "flatpak" source_id = "flatpak"
@@ -125,21 +125,17 @@ class FlatpakSource(ExecutableFormatSource):
executable_format = "flatpak run {flatpak_id}" executable_format = "flatpak run {flatpak_id}"
available_on = {"linux"} available_on = {"linux"}
locations: FlatpakLocations locations = FlatpakLocations(
Location(
def __init__(self) -> None: schema_key="flatpak-location",
super().__init__() candidates=(
self.locations = FlatpakLocations( "/var/lib/flatpak/",
Location( shared.data_dir / "flatpak",
schema_key="flatpak-location", ),
candidates=( paths={
"/var/lib/flatpak/", "applications": LocationSubPath("exports/share/applications", True),
shared.data_dir / "flatpak", "icons": LocationSubPath("exports/share/icons", True),
), },
paths={ invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
"applications": LocationSubPath("exports/share/applications", True),
"icons": LocationSubPath("exports/share/icons", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -21,12 +21,12 @@
import json import json
import logging import logging
from abc import abstractmethod from abc import abstractmethod
from functools import cached_property
from hashlib import sha256 from hashlib import sha256
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Iterable, NamedTuple, Optional, TypedDict from typing import Iterable, NamedTuple, Optional, TypedDict
from functools import cached_property
from src import shared from src import shared
from src.game import Game from src.game import Game
@@ -108,9 +108,7 @@ class SubSourceIterable(Iterable):
"game_id": self.source.game_id_format.format( "game_id": self.source.game_id_format.format(
service=self.service, game_id=app_name service=self.service, game_id=app_name
), ),
"executable": self.source.executable_format.format( "executable": self.source.executable_format.format(runner=runner, app_name=app_name),
runner=runner, app_name=app_name
),
"hidden": self.source_iterable.is_hidden(app_name), "hidden": self.source_iterable.is_hidden(app_name),
} }
game = Game(values) game = Game(values)
@@ -241,7 +239,7 @@ class LegendaryIterable(StoreSubSourceIterable):
else: else:
# Heroic native # Heroic native
logging.debug("Using Heroic native <= 2.8 legendary file") logging.debug("Using Heroic native <= 2.8 legendary file")
path = shared.home / ".config" path = Path.home() / ".config"
path = path / "legendary" / "installed.json" path = path / "legendary" / "installed.json"
logging.debug("Using Heroic %s installed.json path %s", self.name, path) logging.debug("Using Heroic %s installed.json path %s", self.name, path)
@@ -365,31 +363,27 @@ class HeroicSource(URLExecutableSource):
url_format = "heroic://launch/{runner}/{app_name}" url_format = "heroic://launch/{runner}/{app_name}"
available_on = {"linux", "win32"} available_on = {"linux", "win32"}
locations: HeroicLocations locations = HeroicLocations(
Location(
schema_key="heroic-location",
candidates=(
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.flatpak_dir
/ "com.heroicgameslauncher.hgl"
/ "config"
/ "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
@property @property
def game_id_format(self) -> str: def game_id_format(self) -> str:
"""The string format used to construct game IDs""" """The string format used to construct game IDs"""
return self.source_id + "_{service}_{game_id}" return self.source_id + "_{service}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = HeroicLocations(
Location(
schema_key="heroic-location",
candidates=(
shared.config_dir / "heroic",
shared.home / ".config" / "heroic",
shared.flatpak_dir
/ "com.heroicgameslauncher.hgl"
/ "config"
/ "heroic",
shared.appdata_dir / "heroic",
),
paths={
"config.json": LocationSubPath("config.json"),
"store_config.json": LocationSubPath("store/config.json"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)

View File

@@ -86,22 +86,18 @@ class ItchSource(URLExecutableSource):
url_format = "itch://caves/{cave_id}/launch" url_format = "itch://caves/{cave_id}/launch"
available_on = {"linux", "win32"} available_on = {"linux", "win32"}
locations: ItchLocations locations = ItchLocations(
Location(
def __init__(self) -> None: schema_key="itch-location",
super().__init__() candidates=(
self.locations = ItchLocations( shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
Location( shared.config_dir / "itch",
schema_key="itch-location", shared.home / ".config" / "itch",
candidates=( shared.appdata_dir / "itch",
shared.flatpak_dir / "io.itch.itch" / "config" / "itch", ),
shared.config_dir / "itch", paths={
shared.home / ".config" / "itch", "butler.db": LocationSubPath("db/butler.db"),
shared.appdata_dir / "itch", },
), invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
paths={
"butler.db": LocationSubPath("db/butler.db"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
) )
)

View File

@@ -26,11 +26,7 @@ from typing import NamedTuple
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.location import Location, LocationSubPath from src.importer.sources.location import Location, LocationSubPath
from src.importer.sources.source import ( from src.importer.sources.source import Source, SourceIterationResult, SourceIterable
ExecutableFormatSource,
SourceIterationResult,
SourceIterable,
)
class LegendarySourceIterable(SourceIterable): class LegendarySourceIterable(SourceIterable):
@@ -97,28 +93,24 @@ class LegendaryLocations(NamedTuple):
config: Location config: Location
class LegendarySource(ExecutableFormatSource): class LegendarySource(Source):
source_id = "legendary" source_id = "legendary"
name = _("Legendary") name = _("Legendary")
executable_format = "legendary launch {app_name}" executable_format = "legendary launch {app_name}"
available_on = {"linux"} available_on = {"linux"}
iterable_class = LegendarySourceIterable iterable_class = LegendarySourceIterable
locations: LegendaryLocations locations = LegendaryLocations(
Location(
def __init__(self) -> None: schema_key="legendary-location",
super().__init__() candidates=(
self.locations = LegendaryLocations( shared.config_dir / "legendary",
Location( shared.home / ".config" / "legendary",
schema_key="legendary-location", ),
candidates=( paths={
shared.config_dir / "legendary", "installed.json": LocationSubPath("installed.json"),
shared.home / ".config" / "legendary", "metadata": LocationSubPath("metadata", True),
), },
paths={ invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
"installed.json": LocationSubPath("installed.json"),
"metadata": LocationSubPath("metadata", True),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
) )
)

View File

@@ -1,7 +1,7 @@
import logging import logging
from os import PathLike
from pathlib import Path from pathlib import Path
from typing import Iterable, Mapping, NamedTuple, Optional from typing import Mapping, Iterable, NamedTuple
from os import PathLike
from src import shared from src import shared
@@ -41,7 +41,7 @@ class Location:
paths: Mapping[str, LocationSubPath] paths: Mapping[str, LocationSubPath]
invalid_subtitle: str invalid_subtitle: str
root: Optional[Path] = None root: Path = None
def __init__( def __init__(
self, self,
@@ -70,7 +70,7 @@ class Location:
def resolve(self) -> None: def resolve(self) -> None:
"""Choose a root path from the candidates for the location. """Choose a root path from the candidates for the location.
If none fits, raise an UnresolvableLocationError""" If none fits, raise a UnresolvableLocationError"""
if self.root is not None: if self.root is not None:
return return
@@ -94,9 +94,7 @@ class Location:
shared.schema.set_string(self.schema_key, value) shared.schema.set_string(self.schema_key, value)
logging.debug("Resolved value for schema key %s: %s", self.schema_key, value) logging.debug("Resolved value for schema key %s: %s", self.schema_key, value)
def __getitem__(self, key: str) -> Optional[Path]: def __getitem__(self, key: str):
"""Get the computed path from its key for the location""" """Get the computed path from its key for the location"""
self.resolve() self.resolve()
if self.root: return self.root / self.paths[key].segment
return self.root / self.paths[key].segment
return None

View File

@@ -100,37 +100,33 @@ class LutrisSource(URLExecutableSource):
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local... # FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
locations: LutrisLocations locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)
@property @property
def game_id_format(self): def game_id_format(self):
return self.source_id + "_{runner}_{game_id}" return self.source_id + "_{runner}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)

View File

@@ -1,256 +0,0 @@
# retroarch_source.py
#
# Copyright 2023 Rilic
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
#
# SPDX-License-Identifier: GPL-3.0-or-later
import json
import logging
import re
from hashlib import md5
from json import JSONDecodeError
from pathlib import Path
from shlex import quote as shell_quote
from time import time
from typing import NamedTuple
from urllib.parse import quote as url_quote
from src import shared
from src.errors.friendly_error import FriendlyError
from src.game import Game
from src.importer.sources.location import (
Location,
LocationSubPath,
UnresolvableLocationError,
)
from src.importer.sources.source import Source, SourceIterable
from src.importer.sources.steam_source import SteamSource
class RetroarchSourceIterable(SourceIterable):
source: "RetroarchSource"
def get_config_value(self, key: str, config_data: str):
for item in re.findall(f'{key}\\s*=\\s*"(.*)"\n', config_data, re.IGNORECASE):
if item.startswith(":"):
item = item.replace(":", str(self.source.locations.config.root))
logging.debug(str(item))
return item
raise KeyError(f"Key not found in RetroArch config: {key}")
def __iter__(self):
added_time = int(time())
bad_playlists = set()
config_file = self.source.locations.config["retroarch.cfg"]
with config_file.open(encoding="utf-8") as open_file:
config_data = open_file.read()
playlist_folder = Path(
self.get_config_value("playlist_directory", config_data)
).expanduser()
thumbnail_folder = Path(
self.get_config_value("thumbnails_directory", config_data)
).expanduser()
# Get all playlist files, ending in .lpl
playlist_files = playlist_folder.glob("*.lpl")
for playlist_file in playlist_files:
logging.debug(playlist_file)
try:
with playlist_file.open(
encoding="utf-8",
) as open_file:
playlist_json = json.load(open_file)
except (JSONDecodeError, OSError):
logging.warning("Cannot read playlist file: %s", str(playlist_file))
continue
for item in playlist_json["items"]:
# Select the core.
# Try the content's core first, then the playlist's default core.
# If none can be used, warn the user and continue.
for core_path in (
item["core_path"],
playlist_json["default_core_path"],
):
if core_path not in ("DETECT", ""):
break
else:
logging.warning("Cannot find core for: %s", str(item["path"]))
bad_playlists.add(playlist_file.stem)
continue
# Build game
game_id = md5(item["path"].encode("utf-8")).hexdigest()
values = {
"source": self.source.source_id,
"added": added_time,
"name": item["label"],
"game_id": self.source.game_id_format.format(game_id=game_id),
"executable": self.source.make_executable(
core_path=core_path,
rom_path=item["path"],
),
}
game = Game(values)
# Get boxart
boxart_image_name = item["label"] + ".png"
boxart_image_name = re.sub(r"[&\*\/:`<>\?\\\|]", "_", boxart_image_name)
boxart_folder_name = playlist_file.stem
image_path = (
thumbnail_folder
/ boxart_folder_name
/ "Named_Boxarts"
/ boxart_image_name
)
additional_data = {"local_image_path": image_path}
yield (game, additional_data)
if bad_playlists:
raise FriendlyError(
_("No RetroArch Core Selected"),
# The variable is a newline separated list of playlists
_("The following playlists have no default core:")
+ "\n\n{}\n\n".format("\n".join(bad_playlists))
+ _("Games with no core selected were not imported"),
)
class RetroarchLocations(NamedTuple):
config: Location
class RetroarchSource(Source):
name = _("RetroArch")
source_id = "retroarch"
available_on = {"linux"}
iterable_class = RetroarchSourceIterable
locations: RetroarchLocations
def __init__(self) -> None:
super().__init__()
self.locations = RetroarchLocations(
Location(
schema_key="retroarch-location",
candidates=[
shared.flatpak_dir
/ "org.libretro.RetroArch"
/ "config"
/ "retroarch",
shared.config_dir / "retroarch",
shared.home / ".config" / "retroarch",
# TODO: Windows support, waiting for executable path setting improvement
# Path("C:\\RetroArch-Win64"),
# Path("C:\\RetroArch-Win32"),
# TODO: UWP support (URL handler - https://github.com/libretro/RetroArch/pull/13563)
# shared.local_appdata_dir
# / "Packages"
# / "1e4cf179-f3c2-404f-b9f3-cb2070a5aad8_8ngdn9a6dx1ma"
# / "LocalState",
],
paths={
"retroarch.cfg": LocationSubPath("retroarch.cfg"),
},
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
)
)
self.add_steam_location_candidate()
def add_steam_location_candidate(self) -> None:
"""Add the Steam RetroAcrh location to the config candidates"""
try:
self.locations.config.candidates.append(self.get_steam_location())
except (OSError, KeyError, UnresolvableLocationError):
logging.debug("Steam isn't installed")
except ValueError as error:
logging.debug("RetroArch Steam location candiate not found", exc_info=error)
def get_steam_location(self) -> str:
"""
Get the RetroArch installed via Steam location
:raise UnresolvableLocationError: if steam isn't installed
:raise KeyError: if there is no libraryfolders.vdf subpath
:raise OSError: if libraryfolders.vdf can't be opened
:raise ValueError: if RetroArch isn't installed through Steam
"""
# Find steam location
libraryfolders = SteamSource().locations.data["libraryfolders.vdf"]
parse_apps = False
with open(libraryfolders, "r", encoding="utf-8") as open_file:
# Search each line for a library path and store it each time a new one is found.
for line in open_file:
if '"path"' in line:
library_path = re.findall(
'"path"\\s+"(.*)"\n', line, re.IGNORECASE
)[0]
elif '"apps"' in line:
parse_apps = True
elif parse_apps and "}" in line:
parse_apps = False
# Stop searching, as the library path directly above the appid has been found.
elif parse_apps and '"1118310"' in line:
return Path(f"{library_path}/steamapps/common/RetroArch")
# Not found
raise ValueError("RetroArch not found in Steam library")
def make_executable(self, core_path: Path, rom_path: Path) -> str:
"""
Generate an executable command from the rom path and core path,
depending on the source's location.
The format depends on RetroArch's installation method,
detected from the source config location
:param Path rom_path: the game's rom path
:param Path core_path: the game's core path
:return str: an executable command
"""
self.locations.config.resolve()
args = ("-L", core_path, rom_path)
# Steam RetroArch
# (Must check before Flatpak, because Steam itself can be installed as one)
if self.locations.config.root.parent.parent.name == "steamapps":
# steam://run exepects args to be url-encoded and separated by spaces.
args = map(lambda s: url_quote(str(s), safe=""), args)
args_str = " ".join(args)
uri = f"steam://run/1118310//{args_str}/"
return f"xdg-open {shell_quote(uri)}"
# Flatpak RetroArch
args = map(lambda s: shell_quote(str(s)), args)
args_str = " ".join(args)
if self.locations.config.root.is_relative_to(shared.flatpak_dir):
return f"flatpak run org.libretro.RetroArch {args_str}"
# TODO executable override for non-sandboxed sources
# Linux native RetroArch
return f"retroarch {args_str}"
# TODO implement for windows (needs override)

View File

@@ -20,19 +20,19 @@
import sys import sys
from abc import abstractmethod from abc import abstractmethod
from collections.abc import Iterable from collections.abc import Iterable
from typing import Any, Collection, Generator, Optional from typing import Any, Generator, Collection
from src.game import Game from src.game import Game
from src.importer.sources.location import Location from src.importer.sources.location import Location
# Type of the data returned by iterating on a Source # Type of the data returned by iterating on a Source
SourceIterationResult = Optional[Game | tuple[Game, tuple[Any]]] SourceIterationResult = None | Game | tuple[Game, tuple[Any]]
class SourceIterable(Iterable): class SourceIterable(Iterable):
"""Data producer for a source of games""" """Data producer for a source of games"""
source: "Source" source: "Source" = None
def __init__(self, source: "Source") -> None: def __init__(self, source: "Source") -> None:
self.source = source self.source = source
@@ -53,19 +53,16 @@ class Source(Iterable):
source_id: str source_id: str
name: str name: str
variant: Optional[str] = None variant: str = None
available_on: set[str] = set() available_on: set[str] = set()
iterable_class: type[SourceIterable] iterable_class: type[SourceIterable]
# NOTE: Locations must be set at __init__ time, not in the class definition.
# They must not be shared between source instances.
locations: Collection[Location] locations: Collection[Location]
@property @property
def full_name(self) -> str: def full_name(self) -> str:
"""The source's full name""" """The source's full name"""
full_name_ = self.name full_name_ = self.name
if self.variant: if self.variant is not None:
full_name_ += f" ({self.variant})" full_name_ += f" ({self.variant})"
return full_name_ return full_name_
@@ -75,41 +72,29 @@ class Source(Iterable):
return self.source_id + "_{game_id}" return self.source_id + "_{game_id}"
@property @property
def is_available(self) -> bool: def is_available(self):
return sys.platform in self.available_on return sys.platform in self.available_on
@abstractmethod
def make_executable(self, *args, **kwargs) -> str:
"""
Create a game executable command.
Should be implemented by child classes.
"""
def __iter__(self) -> Generator[SourceIterationResult, None, None]:
"""
Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
"""
for location in self.locations:
location.resolve()
return iter(self.iterable_class(self))
class ExecutableFormatSource(Source):
"""Source class that uses a simple executable format to start games"""
@property @property
@abstractmethod @abstractmethod
def executable_format(self) -> str: def executable_format(self) -> str:
"""The executable format used to construct game executables""" """The executable format used to construct game executables"""
def make_executable(self, *args, **kwargs) -> str: def __iter__(self) -> Generator[SourceIterationResult, None, None]:
"""Use the executable format to""" """
return self.executable_format.format(args, kwargs) Get an iterator for the source
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
"""
for location_name in ("data", "cache", "config"):
location = getattr(self, f"{location_name}_location", None)
if location is None:
continue
location.resolve()
return iter(self.iterable_class(self))
# pylint: disable=abstract-method # pylint: disable=abstract-method
class URLExecutableSource(ExecutableFormatSource): class URLExecutableSource(Source):
"""Source class that use custom URLs to start games""" """Source class that use custom URLs to start games"""
url_format: str url_format: str

View File

@@ -120,25 +120,19 @@ class SteamSource(URLExecutableSource):
iterable_class = SteamSourceIterable iterable_class = SteamSourceIterable
url_format = "steam://rungameid/{game_id}" url_format = "steam://rungameid/{game_id}"
locations: SteamLocations locations = SteamLocations(
Location(
def __init__(self) -> None: schema_key="steam-location",
super().__init__() candidates=(
self.locations = SteamLocations( shared.home / ".steam" / "steam",
Location( shared.data_dir / "Steam",
schema_key="steam-location", shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
candidates=( shared.programfiles32_dir / "Steam",
shared.home / ".steam" / "steam", ),
shared.data_dir / "Steam", paths={
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam", "libraryfolders.vdf": LocationSubPath("steamapps/libraryfolders.vdf"),
shared.programfiles32_dir / "Steam", "librarycache": LocationSubPath("appcache/librarycache", True),
), },
paths={ invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
"libraryfolders.vdf": LocationSubPath(
"steamapps/libraryfolders.vdf"
),
"librarycache": LocationSubPath("appcache/librarycache", True),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
)
) )
)

View File

@@ -29,7 +29,7 @@ class ColorLogFormatter(Formatter):
RED = "\033[31m" RED = "\033[31m"
YELLOW = "\033[33m" YELLOW = "\033[33m"
def format(self, record: LogRecord) -> str: def format(self, record: LogRecord):
super_format = super().format(record) super_format = super().format(record)
match record.levelname: match record.levelname:
case "CRITICAL": case "CRITICAL":

View File

@@ -18,12 +18,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import lzma import lzma
from io import TextIOWrapper from io import StringIO
from logging import StreamHandler from logging import StreamHandler
from lzma import FORMAT_XZ, PRESET_DEFAULT from lzma import FORMAT_XZ, PRESET_DEFAULT
from os import PathLike from os import PathLike
from pathlib import Path from pathlib import Path
from typing import Optional
from src import shared from src import shared
@@ -38,7 +37,7 @@ class SessionFileHandler(StreamHandler):
backup_count: int backup_count: int
filename: Path filename: Path
log_file: Optional[TextIOWrapper] = None log_file: StringIO = None
def create_dir(self) -> None: def create_dir(self) -> None:
"""Create the log dir if needed""" """Create the log dir if needed"""
@@ -84,7 +83,7 @@ class SessionFileHandler(StreamHandler):
logfiles.sort(key=self.file_sort_key, reverse=True) logfiles.sort(key=self.file_sort_key, reverse=True)
return logfiles return logfiles
def rotate_file(self, path: Path) -> None: def rotate_file(self, path: Path):
"""Rotate a file's number suffix and remove it if it's too old""" """Rotate a file's number suffix and remove it if it's too old"""
# If uncompressed, compress # If uncompressed, compress
@@ -129,6 +128,5 @@ class SessionFileHandler(StreamHandler):
super().__init__(self.log_file) super().__init__(self.log_file)
def close(self) -> None: def close(self) -> None:
if self.log_file: self.log_file.close()
self.log_file.close()
super().close() super().close()

View File

@@ -27,7 +27,7 @@ import sys
from src import shared from src import shared
def setup_logging() -> None: def setup_logging():
"""Intitate the app's logging""" """Intitate the app's logging"""
is_dev = shared.PROFILE == "development" is_dev = shared.PROFILE == "development"
@@ -89,7 +89,7 @@ def setup_logging() -> None:
logging_dot_config.dictConfig(config) logging_dot_config.dictConfig(config)
def log_system_info() -> None: def log_system_info():
"""Log system debug information""" """Log system debug information"""
logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE) logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE)

View File

@@ -21,7 +21,6 @@ import json
import lzma import lzma
import os import os
import sys import sys
from typing import Any, Optional
import gi import gi
@@ -36,18 +35,18 @@ from src.details_window import DetailsWindow
from src.game import Game from src.game import Game
from src.importer.importer import Importer from src.importer.importer import Importer
from src.importer.sources.bottles_source import BottlesSource from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.dolphin_source import DolphinSource
from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.lutris_source import LutrisSource from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.retroarch_source import RetroarchSource
from src.importer.sources.steam_source import SteamSource from src.importer.sources.steam_source import SteamSource
from src.logging.setup import log_system_info, setup_logging from src.logging.setup import log_system_info, setup_logging
from src.preferences import PreferencesWindow from src.preferences import PreferencesWindow
from src.store.managers.cover_manager import CoverManager
from src.store.managers.display_manager import DisplayManager from src.store.managers.display_manager import DisplayManager
from src.store.managers.file_manager import FileManager from src.store.managers.file_manager import FileManager
from src.store.managers.cover_manager import CoverManager
from src.store.managers.sgdb_manager import SGDBManager from src.store.managers.sgdb_manager import SGDBManager
from src.store.managers.steam_api_manager import SteamAPIManager from src.store.managers.steam_api_manager import SteamAPIManager
from src.store.store import Store from src.store.store import Store
@@ -56,15 +55,15 @@ from src.window import CartridgesWindow
class CartridgesApplication(Adw.Application): class CartridgesApplication(Adw.Application):
win: CartridgesWindow win = None
def __init__(self) -> None: def __init__(self):
shared.store = Store() shared.store = Store()
super().__init__( super().__init__(
application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE application_id=shared.APP_ID, flags=Gio.ApplicationFlags.FLAGS_NONE
) )
def do_activate(self) -> None: # pylint: disable=arguments-differ def do_activate(self): # pylint: disable=arguments-differ
"""Called on app creation""" """Called on app creation"""
setup_logging() setup_logging()
@@ -142,17 +141,14 @@ class CartridgesApplication(Adw.Application):
self.win.present() self.win.present()
def load_games_from_disk(self) -> None: def load_games_from_disk(self):
if shared.games_dir.is_dir(): if shared.games_dir.is_dir():
for game_file in shared.games_dir.iterdir(): for game_file in shared.games_dir.iterdir():
try: data = json.load(game_file.open())
data = json.load(game_file.open())
except (OSError, json.decoder.JSONDecodeError):
continue
game = Game(data) game = Game(data)
shared.store.add_game(game, {"skip_save": True}) shared.store.add_game(game, {"skip_save": True})
def on_about_action(self, *_args: Any) -> None: def on_about_action(self, *_args):
# Get the debug info from the log files # Get the debug info from the log files
debug_str = "" debug_str = ""
for i, path in enumerate(shared.log_files): for i, path in enumerate(shared.log_files):
@@ -177,10 +173,9 @@ class CartridgesApplication(Adw.Application):
developers=[ developers=[
"kramo https://kramo.hu", "kramo https://kramo.hu",
"Geoffrey Coulaud https://geoffrey-coulaud.fr", "Geoffrey Coulaud https://geoffrey-coulaud.fr",
"Rilic https://rilic.red",
"Arcitec https://github.com/Arcitec", "Arcitec https://github.com/Arcitec",
"Paweł Lidwin https://github.com/imLinguin",
"Domenico https://github.com/Domefemia", "Domenico https://github.com/Domefemia",
"Paweł Lidwin https://github.com/imLinguin",
"Rafael Mardojai CM https://mardojai.com", "Rafael Mardojai CM https://mardojai.com",
], ],
designers=("kramo https://kramo.hu",), designers=("kramo https://kramo.hu",),
@@ -196,12 +191,8 @@ class CartridgesApplication(Adw.Application):
about.present() about.present()
def on_preferences_action( def on_preferences_action(
self, self, _action=None, _parameter=None, page_name=None, expander_row=None
_action: Any = None, ):
_parameter: Any = None,
page_name: Optional[str] = None,
expander_row: Optional[str] = None,
) -> CartridgesWindow:
win = PreferencesWindow() win = PreferencesWindow()
if page_name: if page_name:
win.set_visible_page_name(page_name) win.set_visible_page_name(page_name)
@@ -211,76 +202,76 @@ class CartridgesApplication(Adw.Application):
return win return win
def on_launch_game_action(self, *_args: Any) -> None: def on_launch_game_action(self, *_args):
self.win.active_game.launch() self.win.active_game.launch()
def on_hide_game_action(self, *_args: Any) -> None: def on_hide_game_action(self, *_args):
self.win.active_game.toggle_hidden() self.win.active_game.toggle_hidden()
def on_edit_game_action(self, *_args: Any) -> None: def on_edit_game_action(self, *_args):
DetailsWindow(self.win.active_game) DetailsWindow(self.win.active_game)
def on_add_game_action(self, *_args: Any) -> None: def on_add_game_action(self, *_args):
DetailsWindow() DetailsWindow()
def on_import_action(self, *_args: Any) -> None: def on_import_action(self, *_args):
shared.importer = Importer() importer = Importer()
if shared.schema.get_boolean("lutris"): if shared.schema.get_boolean("lutris"):
shared.importer.add_source(LutrisSource()) importer.add_source(LutrisSource())
if shared.schema.get_boolean("steam"): if shared.schema.get_boolean("steam"):
shared.importer.add_source(SteamSource()) importer.add_source(SteamSource())
if shared.schema.get_boolean("heroic"): if shared.schema.get_boolean("heroic"):
shared.importer.add_source(HeroicSource()) importer.add_source(HeroicSource())
if shared.schema.get_boolean("bottles"): if shared.schema.get_boolean("bottles"):
shared.importer.add_source(BottlesSource()) importer.add_source(BottlesSource())
if shared.schema.get_boolean("dolphin"):
importer.add_source(DolphinSource())
if shared.schema.get_boolean("flatpak"): if shared.schema.get_boolean("flatpak"):
shared.importer.add_source(FlatpakSource()) importer.add_source(FlatpakSource())
if shared.schema.get_boolean("itch"): if shared.schema.get_boolean("itch"):
shared.importer.add_source(ItchSource()) importer.add_source(ItchSource())
if shared.schema.get_boolean("legendary"): if shared.schema.get_boolean("legendary"):
shared.importer.add_source(LegendarySource()) importer.add_source(LegendarySource())
if shared.schema.get_boolean("retroarch"): importer.run()
shared.importer.add_source(RetroarchSource())
shared.importer.run() def on_remove_game_action(self, *_args):
def on_remove_game_action(self, *_args: Any) -> None:
self.win.active_game.remove_game() self.win.active_game.remove_game()
def on_remove_game_details_view_action(self, *_args: Any) -> None: def on_remove_game_details_view_action(self, *_args):
if self.win.stack.get_visible_child() == self.win.details_view: if self.win.stack.get_visible_child() == self.win.details_view:
self.on_remove_game_action() self.on_remove_game_action()
def search(self, uri: str) -> None: def search(self, uri):
Gio.AppInfo.launch_default_for_uri(f"{uri}{self.win.active_game.name}") Gio.AppInfo.launch_default_for_uri(f"{uri}{self.win.active_game.name}")
def on_igdb_search_action(self, *_args: Any) -> None: def on_igdb_search_action(self, *_args):
self.search("https://www.igdb.com/search?type=1&q=") self.search("https://www.igdb.com/search?type=1&q=")
def on_sgdb_search_action(self, *_args: Any) -> None: def on_sgdb_search_action(self, *_args):
self.search("https://www.steamgriddb.com/search/grids?term=") self.search("https://www.steamgriddb.com/search/grids?term=")
def on_protondb_search_action(self, *_args: Any) -> None: def on_protondb_search_action(self, *_args):
self.search("https://www.protondb.com/search?q=") self.search("https://www.protondb.com/search?q=")
def on_lutris_search_action(self, *_args: Any) -> None: def on_lutris_search_action(self, *_args):
self.search("https://lutris.net/games?q=") self.search("https://lutris.net/games?q=")
def on_hltb_search_action(self, *_args: Any) -> None: def on_hltb_search_action(self, *_args):
self.search("https://howlongtobeat.com/?q=") self.search("https://howlongtobeat.com/?q=")
def on_quit_action(self, *_args: Any) -> None: def on_quit_action(self, *_args):
self.quit() self.quit()
def create_actions(self, actions: set) -> None: def create_actions(self, actions):
for action in actions: for action in actions:
simple_action = Gio.SimpleAction.new(action[0], None) simple_action = Gio.SimpleAction.new(action[0], None)
@@ -296,7 +287,7 @@ class CartridgesApplication(Adw.Application):
scope.add_action(simple_action) scope.add_action(simple_action)
def main(_version: int) -> Any: def main(_version):
"""App entry point""" """App entry point"""
app = CartridgesApplication() app = CartridgesApplication()
return app.run(sys.argv) return app.run(sys.argv)

View File

@@ -21,20 +21,18 @@ import logging
import re import re
from pathlib import Path from pathlib import Path
from shutil import rmtree from shutil import rmtree
from typing import Any, Callable, Optional
from gi.repository import Adw, Gio, GLib, Gtk from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.game import Game
from src.importer.sources.bottles_source import BottlesSource from src.importer.sources.bottles_source import BottlesSource
from src.importer.sources.dolphin_source import DolphinSource
from src.importer.sources.flatpak_source import FlatpakSource from src.importer.sources.flatpak_source import FlatpakSource
from src.importer.sources.heroic_source import HeroicSource from src.importer.sources.heroic_source import HeroicSource
from src.importer.sources.itch_source import ItchSource from src.importer.sources.itch_source import ItchSource
from src.importer.sources.legendary_source import LegendarySource from src.importer.sources.legendary_source import LegendarySource
from src.importer.sources.location import UnresolvableLocationError from src.importer.sources.location import UnresolvableLocationError
from src.importer.sources.lutris_source import LutrisSource from src.importer.sources.lutris_source import LutrisSource
from src.importer.sources.retroarch_source import RetroarchSource
from src.importer.sources.source import Source from src.importer.sources.source import Source
from src.importer.sources.steam_source import SteamSource from src.importer.sources.steam_source import SteamSource
from src.utils.create_dialog import create_dialog from src.utils.create_dialog import create_dialog
@@ -54,8 +52,6 @@ class PreferencesWindow(Adw.PreferencesWindow):
cover_launches_game_switch = Gtk.Template.Child() cover_launches_game_switch = Gtk.Template.Child()
high_quality_images_switch = Gtk.Template.Child() high_quality_images_switch = Gtk.Template.Child()
remove_missing_switch = Gtk.Template.Child()
steam_expander_row = Gtk.Template.Child() steam_expander_row = Gtk.Template.Child()
steam_data_action_row = Gtk.Template.Child() steam_data_action_row = Gtk.Template.Child()
steam_data_file_chooser_button = Gtk.Template.Child() steam_data_file_chooser_button = Gtk.Template.Child()
@@ -80,6 +76,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
bottles_data_action_row = Gtk.Template.Child() bottles_data_action_row = Gtk.Template.Child()
bottles_data_file_chooser_button = Gtk.Template.Child() bottles_data_file_chooser_button = Gtk.Template.Child()
dolphin_expander_row = Gtk.Template.Child()
dolphin_cache_action_row = Gtk.Template.Child()
dolphin_cache_file_chooser_button = Gtk.Template.Child()
itch_expander_row = Gtk.Template.Child() itch_expander_row = Gtk.Template.Child()
itch_config_action_row = Gtk.Template.Child() itch_config_action_row = Gtk.Template.Child()
itch_config_file_chooser_button = Gtk.Template.Child() itch_config_file_chooser_button = Gtk.Template.Child()
@@ -88,10 +88,6 @@ class PreferencesWindow(Adw.PreferencesWindow):
legendary_config_action_row = Gtk.Template.Child() legendary_config_action_row = Gtk.Template.Child()
legendary_config_file_chooser_button = Gtk.Template.Child() legendary_config_file_chooser_button = Gtk.Template.Child()
retroarch_expander_row = Gtk.Template.Child()
retroarch_config_action_row = Gtk.Template.Child()
retroarch_config_file_chooser_button = Gtk.Template.Child()
flatpak_expander_row = Gtk.Template.Child() flatpak_expander_row = Gtk.Template.Child()
flatpak_data_action_row = Gtk.Template.Child() flatpak_data_action_row = Gtk.Template.Child()
flatpak_data_file_chooser_button = Gtk.Template.Child() flatpak_data_file_chooser_button = Gtk.Template.Child()
@@ -109,10 +105,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
reset_button = Gtk.Template.Child() reset_button = Gtk.Template.Child()
remove_all_games_button = Gtk.Template.Child() remove_all_games_button = Gtk.Template.Child()
removed_games: set[Game] = set() removed_games = set()
warning_menu_buttons: dict = {} warning_menu_buttons = {}
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.win = shared.win self.win = shared.win
self.file_chooser = Gtk.FileDialog() self.file_chooser = Gtk.FileDialog()
@@ -143,12 +139,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Sources settings # Sources settings
for source_class in ( for source_class in (
BottlesSource, BottlesSource,
DolphinSource,
FlatpakSource, FlatpakSource,
HeroicSource, HeroicSource,
ItchSource, ItchSource,
LegendarySource, LegendarySource,
LutrisSource, LutrisSource,
RetroarchSource,
SteamSource, SteamSource,
): ):
source = source_class() source = source_class()
@@ -159,7 +155,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.init_source_row(source) self.init_source_row(source)
# SteamGridDB # SteamGridDB
def sgdb_key_changed(*_args: Any) -> None: def sgdb_key_changed(*_args):
shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text()) shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text())
self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key")) self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key"))
@@ -173,7 +169,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
) )
) )
def set_sgdb_sensitive(widget: Adw.EntryRow) -> None: def set_sgdb_sensitive(widget):
if not widget.get_text(): if not widget.get_text():
shared.schema.set_boolean("sgdb", False) shared.schema.set_boolean("sgdb", False)
@@ -184,11 +180,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
# Switches # Switches
self.bind_switches( self.bind_switches(
{ (
"exit-after-launch", "exit-after-launch",
"cover-launches-game", "cover-launches-game",
"high-quality-images", "high-quality-images",
"remove-missing",
"lutris-import-steam", "lutris-import-steam",
"lutris-import-flatpak", "lutris-import-flatpak",
"heroic-import-epic", "heroic-import-epic",
@@ -199,13 +194,13 @@ class PreferencesWindow(Adw.PreferencesWindow):
"sgdb", "sgdb",
"sgdb-prefer", "sgdb-prefer",
"sgdb-animated", "sgdb-animated",
} )
) )
def get_switch(self, setting: str) -> Any: def get_switch(self, setting):
return getattr(self, f'{setting.replace("-", "_")}_switch') return getattr(self, f'{setting.replace("-", "_")}_switch')
def bind_switches(self, settings: set[str]) -> None: def bind_switches(self, settings):
for setting in settings: for setting in settings:
shared.schema.bind( shared.schema.bind(
setting, setting,
@@ -214,12 +209,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
Gio.SettingsBindFlags.DEFAULT, Gio.SettingsBindFlags.DEFAULT,
) )
def choose_folder( def choose_folder(self, _widget, callback, callback_data=None):
self, _widget: Any, callback: Callable, callback_data: Optional[str] = None
) -> None:
self.file_chooser.select_folder(self.win, None, callback, callback_data) self.file_chooser.select_folder(self.win, None, callback, callback_data)
def undo_remove_all(self, *_args: Any) -> None: def undo_remove_all(self, *_args):
for game in self.removed_games: for game in self.removed_games:
game.removed = False game.removed = False
game.save() game.save()
@@ -228,7 +221,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.removed_games = set() self.removed_games = set()
self.toast.dismiss() self.toast.dismiss()
def remove_all_games(self, *_args: Any) -> None: def remove_all_games(self, *_args):
for game in shared.store: for game in shared.store:
if not game.removed: if not game.removed:
self.removed_games.add(game) self.removed_games.add(game)
@@ -241,7 +234,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
self.add_toast(self.toast) self.add_toast(self.toast)
def reset_app(self, *_args: Any) -> None: def reset_app(self, *_args):
rmtree(shared.data_dir / "cartridges", True) rmtree(shared.data_dir / "cartridges", True)
rmtree(shared.config_dir / "cartridges", True) rmtree(shared.config_dir / "cartridges", True)
rmtree(shared.cache_dir / "cartridges", True) rmtree(shared.cache_dir / "cartridges", True)
@@ -259,24 +252,28 @@ class PreferencesWindow(Adw.PreferencesWindow):
shared.win.get_application().quit() shared.win.get_application().quit()
def update_source_action_row_paths(self, source: Source) -> None: def update_source_action_row_paths(self, source):
"""Set the dir subtitle for a source's action rows""" """Set the dir subtitle for a source's action rows"""
for location_name, location in source.locations._asdict().items(): for location in ("data", "config", "cache"):
# Get the action row to subtitle # Get the action row to subtitle
action_row = getattr( action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None self, f"{source.source_id}_{location}_action_row", None
) )
if not action_row: if not action_row:
continue continue
path = Path(shared.schema.get_string(location.schema_key)).expanduser()
infix = "-cache" if location == "cache" else ""
key = f"{source.source_id}{infix}-location"
path = Path(shared.schema.get_string(key)).expanduser()
# Remove the path prefix if picked via Flatpak portal # Remove the path prefix if picked via Flatpak portal
subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path)) subtitle = re.sub("/run/user/\\d*/doc/.*/", "", str(path))
action_row.set_subtitle(subtitle) action_row.set_subtitle(subtitle)
def resolve_locations(self, source: Source) -> None: def resolve_locations(self, source: Source):
"""Resolve locations and add a warning if location cannot be found""" """Resolve locations and add a warning if location cannot be found"""
def clear_warning_selection(_widget: Any, label: Gtk.Label) -> None: def clear_warning_selection(_widget, label):
label.select_region(-1, -1) label.select_region(-1, -1)
for location_name, location in source.locations._asdict().items(): for location_name, location in source.locations._asdict().items():
@@ -326,30 +323,40 @@ class PreferencesWindow(Adw.PreferencesWindow):
action_row.add_prefix(menu_button) action_row.add_prefix(menu_button)
self.warning_menu_buttons[source.source_id] = menu_button self.warning_menu_buttons[source.source_id] = menu_button
def init_source_row(self, source: Source) -> None: def init_source_row(self, source: Source):
"""Initialize a preference row for a source class""" """Initialize a preference row for a source class"""
def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None: def set_dir(_widget, result, location_name):
"""Callback called when a dir picker button is clicked""" """Callback called when a dir picker button is clicked"""
try: try:
path = Path(self.file_chooser.select_folder_finish(result).get_path()) path = Path(self.file_chooser.select_folder_finish(result).get_path())
except GLib.GError: except GLib.GError:
return return
# Good picked location # Good picked location
location = source.locations._asdict()[location_name] location = getattr(source.locations, location_name)
if location.check_candidate(path): if location.check_candidate(path):
shared.schema.set_string(location.schema_key, str(path)) # Set the schema
match location_name:
case "config" | "data":
infix = ""
case _:
infix = f"-{location_name}"
key = f"{source.source_id}{infix}-location"
value = str(path)
shared.schema.set_string(key, value)
# Update the row
self.update_source_action_row_paths(source) self.update_source_action_row_paths(source)
if self.warning_menu_buttons.get(source.source_id): if self.warning_menu_buttons.get(source.source_id):
action_row = getattr( action_row = getattr(
self, f"{source.source_id}_{location_name}_action_row", None self, f"{source.source_id}_{location_name}_action_row", None
) )
action_row.remove( # type: ignore action_row.remove(self.warning_menu_buttons[source.source_id])
self.warning_menu_buttons[source.source_id]
)
self.warning_menu_buttons.pop(source.source_id) self.warning_menu_buttons.pop(source.source_id)
logging.debug("User-set value for %s is %s", location.schema_key, path)
logging.debug("User-set value for schema key %s: %s", key, value)
# Bad picked location, inform user # Bad picked location, inform user
else: else:
@@ -362,7 +369,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
_("Set Location"), _("Set Location"),
) )
def on_response(widget: Any, response: str) -> None: def on_response(widget, response):
if response == "choose_folder": if response == "choose_folder":
self.choose_folder(widget, set_dir, location_name) self.choose_folder(widget, set_dir, location_name)

View File

@@ -41,7 +41,6 @@ games_dir = data_dir / "cartridges" / "games"
covers_dir = data_dir / "cartridges" / "covers" covers_dir = data_dir / "cartridges" / "covers"
appdata_dir = Path(os.getenv("appdata") or "C:\\Users\\Default\\AppData\\Roaming") appdata_dir = Path(os.getenv("appdata") or "C:\\Users\\Default\\AppData\\Roaming")
local_appdata_dir = Path(os.getenv("csidl_local_appdata") or "C:\\Users\\Default\\AppData\\Local")
programfiles32_dir = Path(os.getenv("programfiles(x86)") or "C:\\Program Files (x86)") programfiles32_dir = Path(os.getenv("programfiles(x86)") or "C:\\Program Files (x86)")
scale_factor = max( scale_factor = max(

View File

@@ -46,7 +46,7 @@ class Manager(ErrorProducer):
max_tries: int = 3 max_tries: int = 3
@property @property
def name(self) -> str: def name(self):
return type(self).__name__ return type(self).__name__
@abstractmethod @abstractmethod
@@ -59,13 +59,13 @@ class Manager(ErrorProducer):
* May raise other exceptions that will be reported * May raise other exceptions that will be reported
""" """
def run(self, game: Game, additional_data: dict) -> None: def run(self, game: Game, additional_data: dict):
"""Handle errors (retry, ignore or raise) that occur in the manager logic""" """Handle errors (retry, ignore or raise) that occur in the manager logic"""
# Keep track of the number of tries # Keep track of the number of tries
tries = 1 tries = 1
def handle_error(error: Exception) -> None: def handle_error(error: Exception):
nonlocal tries nonlocal tries
# If FriendlyError, handle its cause instead # If FriendlyError, handle its cause instead
@@ -83,11 +83,11 @@ class Manager(ErrorProducer):
retrying_format = "Retrying %s in %s for %s" retrying_format = "Retrying %s in %s for %s"
unretryable_format = "Unretryable %s in %s for %s" unretryable_format = "Unretryable %s in %s for %s"
if type(error) in self.continue_on: if error in self.continue_on:
# Handle skippable errors (skip silently) # Handle skippable errors (skip silently)
return return
if type(error) in self.retryable_on: if error in self.retryable_on:
if tries > self.max_tries: if tries > self.max_tries:
# Handle being out of retries # Handle being out of retries
logging.error(out_of_retries_format, *log_args) logging.error(out_of_retries_format, *log_args)
@@ -104,7 +104,7 @@ class Manager(ErrorProducer):
logging.error(unretryable_format, *log_args, exc_info=error) logging.error(unretryable_format, *log_args, exc_info=error)
self.report_error(base_error) self.report_error(base_error)
def try_manager_logic() -> None: def try_manager_logic():
try: try:
self.main(game, additional_data) self.main(game, additional_data)
except Exception as error: # pylint: disable=broad-exception-caught except Exception as error: # pylint: disable=broad-exception-caught

View File

@@ -83,7 +83,7 @@ class Pipeline(GObject.Object):
progress = 1 progress = 1
return progress return progress
def advance(self) -> None: def advance(self):
"""Spawn tasks for managers that are able to run for a game""" """Spawn tasks for managers that are able to run for a game"""
# Separate blocking / async managers # Separate blocking / async managers
@@ -106,5 +106,5 @@ class Pipeline(GObject.Object):
self.advance() self.advance()
@GObject.Signal(name="advanced") @GObject.Signal(name="advanced")
def advanced(self): # type: ignore def advanced(self) -> None:
"""Signal emitted when the pipeline has advanced""" """Signal emitted when the pipeline has advanced"""

View File

@@ -18,7 +18,7 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
import logging import logging
from typing import Any, Generator, MutableMapping, Optional from typing import MutableMapping, Generator, Any
from src import shared from src import shared
from src.game import Game from src.game import Game
@@ -33,16 +33,12 @@ class Store:
pipeline_managers: set[Manager] pipeline_managers: set[Manager]
pipelines: dict[str, Pipeline] pipelines: dict[str, Pipeline]
source_games: MutableMapping[str, MutableMapping[str, Game]] source_games: MutableMapping[str, MutableMapping[str, Game]]
new_game_ids: set[str]
duplicate_game_ids: set[str]
def __init__(self) -> None: def __init__(self) -> None:
self.managers = {} self.managers = {}
self.pipeline_managers = set() self.pipeline_managers = set()
self.pipelines = {} self.pipelines = {}
self.source_games = {} self.source_games = {}
self.new_game_ids = set()
self.duplicate_game_ids = set()
def __contains__(self, obj: object) -> bool: def __contains__(self, obj: object) -> bool:
"""Check if the game is present in the store with the `in` keyword""" """Check if the game is present in the store with the `in` keyword"""
@@ -77,15 +73,13 @@ class Store:
except KeyError: except KeyError:
return default return default
def add_manager(self, manager: Manager, in_pipeline: bool = True) -> None: def add_manager(self, manager: Manager, in_pipeline=True):
"""Add a manager to the store""" """Add a manager to the store"""
manager_type = type(manager) manager_type = type(manager)
self.managers[manager_type] = manager self.managers[manager_type] = manager
self.toggle_manager_in_pipelines(manager_type, in_pipeline) self.toggle_manager_in_pipelines(manager_type, in_pipeline)
def toggle_manager_in_pipelines( def toggle_manager_in_pipelines(self, manager_type: type[Manager], enable: bool):
self, manager_type: type[Manager], enable: bool
) -> None:
"""Change if a manager should run in new pipelines""" """Change if a manager should run in new pipelines"""
if enable: if enable:
self.pipeline_managers.add(self.managers[manager_type]) self.pipeline_managers.add(self.managers[manager_type])
@@ -93,7 +87,7 @@ class Store:
self.pipeline_managers.discard(self.managers[manager_type]) self.pipeline_managers.discard(self.managers[manager_type])
def cleanup_game(self, game: Game) -> None: def cleanup_game(self, game: Game) -> None:
"""Remove a game's files, dismiss any loose toasts""" """Remove a game's files"""
for path in ( for path in (
shared.games_dir / f"{game.game_id}.json", shared.games_dir / f"{game.game_id}.json",
shared.covers_dir / f"{game.game_id}.tiff", shared.covers_dir / f"{game.game_id}.tiff",
@@ -101,17 +95,9 @@ class Store:
): ):
path.unlink(missing_ok=True) path.unlink(missing_ok=True)
# TODO: don't run this if the state is startup
for undo in ("remove", "hide"):
try:
shared.win.toasts[(game, undo)].dismiss()
shared.win.toasts.pop((game, undo))
except KeyError:
pass
def add_game( def add_game(
self, game: Game, additional_data: dict, run_pipeline: bool = True self, game: Game, additional_data: dict, run_pipeline=True
) -> Optional[Pipeline]: ) -> Pipeline | None:
"""Add a game to the app""" """Add a game to the app"""
# Ignore games from a newer spec version # Ignore games from a newer spec version
@@ -128,7 +114,6 @@ class Store:
if not stored_game: if not stored_game:
# New game, do as normal # New game, do as normal
logging.debug("New store game %s (%s)", game.name, game.game_id) logging.debug("New store game %s (%s)", game.name, game.game_id)
self.new_game_ids.add(game.game_id)
elif stored_game.removed: elif stored_game.removed:
# Will replace a removed game, cleanup its remains # Will replace a removed game, cleanup its remains
logging.debug( logging.debug(
@@ -137,11 +122,9 @@ class Store:
game.game_id, game.game_id,
) )
self.cleanup_game(stored_game) self.cleanup_game(stored_game)
self.new_game_ids.add(game.game_id)
else: else:
# Duplicate game, ignore it # Duplicate game, ignore it
logging.debug("Duplicate store game %s (%s)", game.name, game.game_id) logging.debug("Duplicate store game %s (%s)", game.name, game.game_id)
self.duplicate_game_ids.add(game.game_id)
return None return None
# Connect signals # Connect signals

View File

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

View File

@@ -17,18 +17,10 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from typing import Optional from gi.repository import Adw
from gi.repository import Adw, Gtk
def create_dialog( def create_dialog(win, heading, body, extra_option=None, extra_label=None):
win: Gtk.Window,
heading: str,
body: str,
extra_option: Optional[str] = None,
extra_label: Optional[str] = None,
) -> Adw.MessageDialog:
dialog = Adw.MessageDialog.new(win, heading, body) dialog = Adw.MessageDialog.new(win, heading, body)
dialog.add_response("dismiss", _("Dismiss")) dialog.add_response("dismiss", _("Dismiss"))

View File

@@ -0,0 +1,146 @@
"""Reads the Dolphin game database, stored in a binary format"""
# Copyright 2022-2023 strycore - Lutris
# Copyright 2023 Rilic
import logging
from pathlib import Path
SUPPORTED_CACHE_VERSION = 24
def get_hex_string(string):
"""Return the hexadecimal representation of a string"""
return " ".join("{:02x}".format(c) for c in string)
def get_word_len(string):
"""Return the length of a string as specified in the Dolphin format"""
return int("0x" + "".join("{:02x}".format(c) for c in string[::-1]), 0)
# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.h#L140
# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.cpp#L318
class DolphinCacheReader:
header_size = 20
structure = {
"valid": "b",
"file_path": "s",
"file_name": "s",
"file_size": 8,
"volume_size": 8,
"volume_size_type": 4,
"is_datel_disc": 1,
"is_nkit": 1,
"short_names": "a",
"long_names": "a",
"short_makers": "a",
"long_makers": "a",
"descriptions": "a",
"internal_name": "s",
"game_id": "s",
"gametdb_id": "s",
"title_id": 8,
"maker_id": "s",
"region": 4,
"country": 4,
"platform": 1,
"platform_": 3,
"blob_type": 4,
"block_size": 8,
"compression_method": "s",
"revision": 2,
"disc_number": 1,
"apploader_date": "s",
"custom_name": "s",
"custom_description": "s",
"custom_maker": "s",
"volume_banner": "i",
"custom_banner": "i",
"default_cover": "c",
"custom_cover": "c",
}
def __init__(self, cache_file: Path):
self.offset = 0
with open(cache_file, "rb") as dolphin_cache_file:
self.cache_content = dolphin_cache_file.read()
cache_version = get_word_len(self.cache_content[:4])
if cache_version != SUPPORTED_CACHE_VERSION:
logging.warning(
"Dolphin cache version expected %s but found %s",
SUPPORTED_CACHE_VERSION,
cache_version,
)
def get_game(self):
game = {}
for key, i in self.structure.items():
if i == "s":
game[key] = self.get_string()
elif i == "b":
game[key] = self.get_boolean()
elif i == "a":
game[key] = self.get_array()
elif i == "i":
game[key] = self.get_image()
elif i == "c":
game[key] = self.get_cover()
else:
game[key] = self.get_raw(i)
return game
def get_games(self):
self.offset += self.header_size
games = []
while self.offset < len(self.cache_content):
try:
games.append(self.get_game())
except Exception as ex:
logging.error("Failed to read Dolphin database: %s", ex)
return games
def get_boolean(self):
res = bool(get_word_len(self.cache_content[self.offset : self.offset + 1]))
self.offset += 1
return res
def get_array(self):
array_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
array = {}
for _i in range(array_len):
array_key = self.get_raw(4)
array[array_key] = self.get_string()
return array
def get_image(self):
data_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
res = self.cache_content[
self.offset : self.offset + data_len * 4
] # vector<u32>
self.offset += data_len * 4
width = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
height = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
return (width, height), res
def get_cover(self):
array_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
return self.get_raw(array_len)
def get_raw(self, word_len):
res = get_hex_string(self.cache_content[self.offset : self.offset + word_len])
self.offset += word_len
return res
def get_string(self):
word_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
self.offset += 4
string = self.cache_content[self.offset : self.offset + word_len]
self.offset += word_len
return string.decode("utf8")

View File

@@ -23,14 +23,14 @@ from pathlib import Path
from src import shared from src import shared
old_data_dir = shared.home / ".local" / "share" old_data_dir = Path.home() / ".local" / "share"
old_cartridges_data_dir = old_data_dir / "cartridges" old_cartridges_data_dir = old_data_dir / "cartridges"
migrated_file_path = old_cartridges_data_dir / ".migrated" migrated_file_path = old_cartridges_data_dir / ".migrated"
old_games_dir = old_cartridges_data_dir / "games" old_games_dir = old_cartridges_data_dir / "games"
old_covers_dir = old_cartridges_data_dir / "covers" old_covers_dir = old_cartridges_data_dir / "covers"
def migrate_game_covers(game_path: Path) -> None: def migrate_game_covers(game_path: Path):
"""Migrate a game covers from a source game path to the current dir""" """Migrate a game covers from a source game path to the current dir"""
for suffix in (".tiff", ".gif"): for suffix in (".tiff", ".gif"):
cover_path = old_covers_dir / game_path.with_suffix(suffix).name cover_path = old_covers_dir / game_path.with_suffix(suffix).name
@@ -41,7 +41,7 @@ def migrate_game_covers(game_path: Path) -> None:
cover_path.rename(destination_cover_path) cover_path.rename(destination_cover_path)
def migrate_files_v1_to_v2() -> None: def migrate_files_v1_to_v2():
""" """
Migrate user data from the v1.X locations to the latest location. Migrate user data from the v1.X locations to the latest location.

View File

@@ -17,11 +17,11 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # 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 collections import deque
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from threading import BoundedSemaphore, Lock, Thread
from time import sleep, time
from typing import Any, Sized
class PickHistory(Sized): class PickHistory(Sized):
@@ -30,22 +30,22 @@ class PickHistory(Sized):
period: int period: int
timestamps: list[float] timestamps: list[int] = None
timestamps_lock: Lock timestamps_lock: Lock = None
def __init__(self, period: int) -> None: def __init__(self, period: int) -> None:
self.period = period self.period = period
self.timestamps = [] self.timestamps = []
self.timestamps_lock = Lock() self.timestamps_lock = Lock()
def remove_old_entries(self) -> None: def remove_old_entries(self):
"""Remove history entries older than the period""" """Remove history entries older than the period"""
now = time() now = time()
cutoff = now - self.period cutoff = now - self.period
with self.timestamps_lock: with self.timestamps_lock:
self.timestamps = [entry for entry in self.timestamps if entry > cutoff] self.timestamps = [entry for entry in self.timestamps if entry > cutoff]
def add(self, *new_timestamps: float) -> None: def add(self, *new_timestamps: Optional[int]):
"""Add timestamps to the history. """Add timestamps to the history.
If none given, will add the current timestamp""" If none given, will add the current timestamp"""
if len(new_timestamps) == 0: if len(new_timestamps) == 0:
@@ -60,7 +60,7 @@ class PickHistory(Sized):
return len(self.timestamps) return len(self.timestamps)
@property @property
def start(self) -> float: def start(self) -> int:
"""Get the time at which the history started""" """Get the time at which the history started"""
self.remove_old_entries() self.remove_old_entries()
with self.timestamps_lock: with self.timestamps_lock:
@@ -70,7 +70,7 @@ class PickHistory(Sized):
entry = time() entry = time()
return entry return entry
def copy_timestamps(self) -> list[float]: def copy_timestamps(self) -> str:
"""Get a copy of the timestamps history""" """Get a copy of the timestamps history"""
self.remove_old_entries() self.remove_old_entries()
with self.timestamps_lock: with self.timestamps_lock:
@@ -79,55 +79,51 @@ class PickHistory(Sized):
# pylint: disable=too-many-instance-attributes # pylint: disable=too-many-instance-attributes
class RateLimiter(AbstractContextManager): class RateLimiter(AbstractContextManager):
""" """Rate limiter implementing the token bucket algorithm"""
Base rate limiter implementing the token bucket algorithm.
Do not use directly, create a child class to tailor the rate limiting to the
underlying service's limits.
Subclasses must provide values to the following attributes:
* refill_period_seconds - Period in which we have a max amount of tokens
* refill_period_tokens - Number of tokens allowed in this period
* burst_tokens - Max number of tokens that can be consumed instantly
"""
# Period in which we have a max amount of tokens
refill_period_seconds: int refill_period_seconds: int
# Number of tokens allowed in this period
refill_period_tokens: int refill_period_tokens: int
# Max number of tokens that can be consumed instantly
burst_tokens: int burst_tokens: int
pick_history: PickHistory pick_history: PickHistory = None
bucket: BoundedSemaphore bucket: BoundedSemaphore = None
queue: deque[Lock] queue: deque[Lock] = None
queue_lock: Lock queue_lock: Lock = None
# Protect the number of tokens behind a lock # Protect the number of tokens behind a lock
__n_tokens_lock: Lock __n_tokens_lock: Lock = None
__n_tokens = 0 __n_tokens = 0
@property @property
def n_tokens(self) -> int: def n_tokens(self):
with self.__n_tokens_lock: with self.__n_tokens_lock:
return self.__n_tokens return self.__n_tokens
@n_tokens.setter @n_tokens.setter
def n_tokens(self, value: int) -> None: def n_tokens(self, value: int):
with self.__n_tokens_lock: with self.__n_tokens_lock:
self.__n_tokens = value self.__n_tokens = value
def _init_pick_history(self) -> None: def __init__(
""" self,
Initialize the tocken pick history refill_period_seconds: Optional[int] = None,
(only for use in this class and its children) refill_period_tokens: Optional[int] = None,
burst_tokens: Optional[int] = None,
By default, creates an empty pick history. ) -> None:
Should be overriden or extended by subclasses.
"""
self.pick_history = PickHistory(self.refill_period_seconds)
def __init__(self) -> None:
"""Initialize the limiter""" """Initialize the limiter"""
self._init_pick_history() # 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 # Create synchronization data
self.__n_tokens_lock = Lock() self.__n_tokens_lock = Lock()
@@ -151,8 +147,8 @@ class RateLimiter(AbstractContextManager):
""" """
# Compute ideal spacing # Compute ideal spacing
tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore tokens_left = self.refill_period_tokens - len(self.pick_history)
seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore seconds_left = self.pick_history.start + self.refill_period_seconds - time()
try: try:
spacing_seconds = seconds_left / tokens_left spacing_seconds = seconds_left / tokens_left
except ZeroDivisionError: except ZeroDivisionError:
@@ -163,7 +159,7 @@ class RateLimiter(AbstractContextManager):
natural_spacing = self.refill_period_seconds / self.refill_period_tokens natural_spacing = self.refill_period_seconds / self.refill_period_tokens
return max(natural_spacing, spacing_seconds) return max(natural_spacing, spacing_seconds)
def refill(self) -> None: def refill(self):
"""Add a token back in the bucket""" """Add a token back in the bucket"""
sleep(self.refill_spacing) sleep(self.refill_spacing)
try: try:
@@ -174,7 +170,7 @@ class RateLimiter(AbstractContextManager):
else: else:
self.n_tokens += 1 self.n_tokens += 1
def refill_thread_func(self) -> None: def refill_thread_func(self):
"""Entry point for the daemon thread that is refilling the bucket""" """Entry point for the daemon thread that is refilling the bucket"""
while True: while True:
self.refill() self.refill()
@@ -204,18 +200,18 @@ class RateLimiter(AbstractContextManager):
self.queue.appendleft(lock) self.queue.appendleft(lock)
return lock return lock
def acquire(self) -> None: def acquire(self):
"""Acquires a token from the bucket when it's your turn in queue""" """Acquires a token from the bucket when it's your turn in queue"""
lock = self.add_to_queue() lock = self.add_to_queue()
self.update_queue() self.update_queue()
# Wait until our turn in queue # Wait until our turn in queue
lock.acquire() # pylint: disable=consider-using-with lock.acquire() # pylint: disable=consider-using-with
self.pick_history.add() # type: ignore self.pick_history.add()
# --- Support for use in with statements # --- Support for use in with statements
def __enter__(self) -> None: def __enter__(self):
self.acquire() self.acquire()
def __exit__(self, *_args: Any) -> None: def __exit__(self, *_args):
pass pass

View File

@@ -18,12 +18,11 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from datetime import datetime from datetime import datetime
from typing import Any
from gi.repository import GLib from gi.repository import GLib
def relative_date(timestamp: int) -> Any: # pylint: disable=too-many-return-statements def relative_date(timestamp): # pylint: disable=too-many-return-statements
days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days
if days_no == 0: if days_no == 0:

View File

@@ -20,17 +20,14 @@
from pathlib import Path from pathlib import Path
from shutil import copyfile from shutil import copyfile
from typing import Optional
from gi.repository import Gdk, GdkPixbuf, Gio, GLib from gi.repository import Gdk, Gio, GLib
from PIL import Image, ImageSequence, UnidentifiedImageError from PIL import Image, ImageSequence, UnidentifiedImageError
from src import shared from src import shared
def resize_cover( def resize_cover(cover_path=None, pixbuf=None):
cover_path: Optional[Path] = None, pixbuf: Optional[GdkPixbuf.Pixbuf] = None
) -> Optional[Path]:
if not cover_path and not pixbuf: if not cover_path and not pixbuf:
return None return None
@@ -77,7 +74,7 @@ def resize_cover(
return tmp_path return tmp_path
def save_cover(game_id: str, cover_path: Path) -> None: def save_cover(game_id, cover_path):
shared.covers_dir.mkdir(parents=True, exist_ok=True) shared.covers_dir.mkdir(parents=True, exist_ok=True)
animated_path = shared.covers_dir / f"{game_id}.gif" animated_path = shared.covers_dir / f"{game_id}.gif"

View File

@@ -21,14 +21,13 @@
import json import json
import logging import logging
import re import re
from pathlib import Path
from typing import TypedDict from typing import TypedDict
import requests import requests
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from src import shared from src import shared
from src.utils.rate_limiter import RateLimiter from src.utils.rate_limiter import PickHistory, RateLimiter
class SteamError(Exception): class SteamError(Exception):
@@ -72,18 +71,16 @@ class SteamRateLimiter(RateLimiter):
refill_period_tokens = 200 refill_period_tokens = 200
burst_tokens = 100 burst_tokens = 100
def _init_pick_history(self) -> None: def __init__(self) -> None:
""" # Load pick history from schema
Load the pick history from schema. # (Remember API limits through restarts of Cartridges)
Allows remembering API limits through restarts of Cartridges.
"""
super()._init_pick_history()
timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history") 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.add(*json.loads(timestamps_str))
self.pick_history.remove_old_entries() self.pick_history.remove_old_entries()
super().__init__()
def acquire(self) -> None: def acquire(self):
"""Get a token from the bucket and store the pick history in the schema""" """Get a token from the bucket and store the pick history in the schema"""
super().acquire() super().acquire()
timestamps_str = json.dumps(self.pick_history.copy_timestamps()) timestamps_str = json.dumps(self.pick_history.copy_timestamps())
@@ -93,7 +90,7 @@ class SteamRateLimiter(RateLimiter):
class SteamFileHelper: class SteamFileHelper:
"""Helper for steam file formats""" """Helper for steam file formats"""
def get_manifest_data(self, manifest_path: Path) -> SteamManifestData: def get_manifest_data(self, manifest_path) -> SteamManifestData:
"""Get local data for a game from its manifest""" """Get local data for a game from its manifest"""
with open(manifest_path, "r", encoding="utf-8") as file: with open(manifest_path, "r", encoding="utf-8") as file:
@@ -107,11 +104,7 @@ class SteamFileHelper:
raise SteamInvalidManifestError() raise SteamInvalidManifestError()
data[key] = match.group(1) data[key] = match.group(1)
return SteamManifestData( return SteamManifestData(**data)
name=data["name"],
appid=data["appid"],
stateflags=data["stateflags"],
)
class SteamAPIHelper: class SteamAPIHelper:
@@ -123,7 +116,7 @@ class SteamAPIHelper:
def __init__(self, rate_limiter: RateLimiter) -> None: def __init__(self, rate_limiter: RateLimiter) -> None:
self.rate_limiter = rate_limiter self.rate_limiter = rate_limiter
def get_api_data(self, appid: str) -> SteamAPIData: def get_api_data(self, appid) -> SteamAPIData:
""" """
Get online data for a game from its appid. Get online data for a game from its appid.
May block to satisfy the Steam web API limitations. May block to satisfy the Steam web API limitations.

View File

@@ -20,14 +20,12 @@
import logging import logging
from pathlib import Path from pathlib import Path
from typing import Any
import requests import requests
from gi.repository import Gio from gi.repository import Gio
from requests.exceptions import HTTPError from requests.exceptions import HTTPError
from src import shared from src import shared
from src.game import Game
from src.utils.save_cover import resize_cover, save_cover from src.utils.save_cover import resize_cover, save_cover
@@ -57,12 +55,12 @@ class SGDBHelper:
base_url = "https://www.steamgriddb.com/api/v2/" base_url = "https://www.steamgriddb.com/api/v2/"
@property @property
def auth_headers(self) -> dict[str, str]: def auth_headers(self):
key = shared.schema.get_string("sgdb-key") key = shared.schema.get_string("sgdb-key")
headers = {"Authorization": f"Bearer {key}"} headers = {"Authorization": f"Bearer {key}"}
return headers return headers
def get_game_id(self, game: Game) -> Any: def get_game_id(self, game):
"""Get grid results for a game. Can raise an exception.""" """Get grid results for a game. Can raise an exception."""
uri = f"{self.base_url}search/autocomplete/{game.name}" uri = f"{self.base_url}search/autocomplete/{game.name}"
res = requests.get(uri, headers=self.auth_headers, timeout=5) res = requests.get(uri, headers=self.auth_headers, timeout=5)
@@ -76,7 +74,7 @@ class SGDBHelper:
case _: case _:
res.raise_for_status() res.raise_for_status()
def get_image_uri(self, game_id: str, animated: bool = False) -> Any: def get_image_uri(self, game_id, animated=False):
"""Get the image for a SGDB game id""" """Get the image for a SGDB game id"""
uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900" uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900"
if animated: if animated:
@@ -95,7 +93,7 @@ class SGDBHelper:
case _: case _:
res.raise_for_status() res.raise_for_status()
def conditionaly_update_cover(self, game: Game) -> None: def conditionaly_update_cover(self, game):
"""Update the game's cover if appropriate""" """Update the game's cover if appropriate"""
# Obvious skips # Obvious skips

View File

@@ -18,28 +18,25 @@
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from functools import wraps from functools import wraps
from typing import Any, Callable
from gi.repository import Gio from gi.repository import Gio
def create_task_thread_func_closure(func: Callable, data: Any) -> Callable: def create_task_thread_func_closure(func, data):
"""Wrap a Gio.TaskThreadFunc with the given data in a closure""" """Wrap a Gio.TaskThreadFunc with the given data in a closure"""
def closure( def closure(task, source_object, _data, cancellable):
task: Gio.Task, source_object: object, _data: Any, cancellable: Gio.Cancellable
) -> Any:
func(task, source_object, data, cancellable) func(task, source_object, data, cancellable)
return closure return closure
def decorate_set_task_data(task: Gio.Task) -> Callable: def decorate_set_task_data(task):
"""Decorate Gio.Task.set_task_data to replace it""" """Decorate Gio.Task.set_task_data to replace it"""
def decorator(original_method: Callable) -> Callable: def decorator(original_method):
@wraps(original_method) @wraps(original_method)
def new_method(task_data: Any) -> None: def new_method(task_data):
task.task_data = task_data task.task_data = task_data
return new_method return new_method
@@ -47,13 +44,13 @@ def decorate_set_task_data(task: Gio.Task) -> Callable:
return decorator return decorator
def decorate_run_in_thread(task: Gio.Task) -> Callable: def decorate_run_in_thread(task):
"""Decorate Gio.Task.run_in_thread to pass the task data correctly """Decorate Gio.Task.run_in_thread to pass the task data correctly
Creates a closure around task_thread_func with the task data available.""" Creates a closure around task_thread_func with the task data available."""
def decorator(original_method: Callable) -> Callable: def decorator(original_method):
@wraps(original_method) @wraps(original_method)
def new_method(task_thread_func: Callable) -> None: def new_method(task_thread_func):
closure = create_task_thread_func_closure(task_thread_func, task.task_data) closure = create_task_thread_func_closure(task_thread_func, task.task_data)
original_method(closure) original_method(closure)
@@ -67,17 +64,11 @@ class Task:
"""Wrapper around Gio.Task to patch task data not being passed""" """Wrapper around Gio.Task to patch task data not being passed"""
@classmethod @classmethod
def new( def new(cls, source_object, cancellable, callback, callback_data):
cls,
source_object: object,
cancellable: Gio.Cancellable,
callback: Callable,
callback_data: Any,
) -> Gio.Task:
"""Create a new, monkey-patched Gio.Task. """Create a new, monkey-patched Gio.Task.
The `set_task_data` and `run_in_thread` methods are decorated. 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 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. 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 This class is supposed to make Gio.Task comply with its expected behaviour
per the docs: per the docs:

View File

@@ -17,13 +17,9 @@
# #
# SPDX-License-Identifier: GPL-3.0-or-later # SPDX-License-Identifier: GPL-3.0-or-later
from typing import Any, Optional from gi.repository import Adw, Gtk
from gi.repository import Adw, Gio, GLib, Gtk
from src import shared from src import shared
from src.game import Game
from src.game_cover import GameCover
from src.utils.relative_date import relative_date from src.utils.relative_date import relative_date
@@ -68,13 +64,13 @@ class CartridgesWindow(Adw.ApplicationWindow):
hidden_search_entry = Gtk.Template.Child() hidden_search_entry = Gtk.Template.Child()
hidden_search_button = Gtk.Template.Child() hidden_search_button = Gtk.Template.Child()
game_covers: dict = {} game_covers = {}
toasts: dict = {} toasts = {}
active_game: Game active_game = None
details_view_game_cover: Optional[GameCover] = None details_view_game_cover = None
sort_state: str = "a-z" sort_state = "a-z"
def __init__(self, **kwargs: Any) -> None: def __init__(self, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.previous_page = self.library_view self.previous_page = self.library_view
@@ -114,11 +110,11 @@ class CartridgesWindow(Adw.ApplicationWindow):
style_manager.connect("notify::dark", self.set_details_view_opacity) style_manager.connect("notify::dark", self.set_details_view_opacity)
style_manager.connect("notify::high-contrast", self.set_details_view_opacity) style_manager.connect("notify::high-contrast", self.set_details_view_opacity)
def search_changed(self, _widget: Any, hidden: bool) -> None: def search_changed(self, _widget, hidden):
# Refresh search filter on keystroke in search box # Refresh search filter on keystroke in search box
(self.hidden_library if hidden else self.library).invalidate_filter() (self.hidden_library if hidden else self.library).invalidate_filter()
def set_library_child(self) -> None: def set_library_child(self):
child, hidden_child = self.notice_empty, self.hidden_notice_empty child, hidden_child = self.notice_empty, self.hidden_notice_empty
for game in shared.store: for game in shared.store:
@@ -138,7 +134,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.library_bin.set_child(child) self.library_bin.set_child(child)
self.hidden_library_bin.set_child(hidden_child) self.hidden_library_bin.set_child(hidden_child)
def filter_func(self, child: Gtk.Widget) -> bool: def filter_func(self, child):
game = child.get_child() game = child.get_child()
text = ( text = (
( (
@@ -160,10 +156,10 @@ class CartridgesWindow(Adw.ApplicationWindow):
return not filtered return not filtered
def set_active_game(self, _widget: Any, _pspec: Any, game: Game) -> None: def set_active_game(self, _widget, _pspec, game):
self.active_game = game self.active_game = game
def show_details_view(self, game: Game) -> None: def show_details_view(self, game):
self.active_game = game self.active_game = game
self.details_view_cover.set_opacity(int(not game.loading)) self.details_view_cover.set_opacity(int(not game.loading))
@@ -211,7 +207,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.set_details_view_opacity() self.set_details_view_opacity()
def set_details_view_opacity(self, *_args: Any) -> None: def set_details_view_opacity(self, *_args):
if self.stack.get_visible_child() != self.details_view: if self.stack.get_visible_child() != self.details_view:
return return
@@ -222,12 +218,12 @@ class CartridgesWindow(Adw.ApplicationWindow):
return return
self.details_view_blurred_cover.set_opacity( self.details_view_blurred_cover.set_opacity(
1 - self.details_view_game_cover.luminance[0] # type: ignore 1 - self.details_view_game_cover.luminance[0]
if style_manager.get_dark() if style_manager.get_dark()
else self.details_view_game_cover.luminance[1] # type: ignore else self.details_view_game_cover.luminance[1]
) )
def sort_func(self, child1: Gtk.Widget, child2: Gtk.Widget) -> int: def sort_func(self, child1, child2):
var, order = "name", True var, order = "name", True
if self.sort_state in ("newest", "oldest"): if self.sort_state in ("newest", "oldest"):
@@ -237,7 +233,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
elif self.sort_state == "a-z": elif self.sort_state == "a-z":
order = False order = False
def get_value(index: int) -> str: def get_value(index):
return str( return str(
getattr((child1.get_child(), child2.get_child())[index], var) getattr((child1.get_child(), child2.get_child())[index], var)
).lower() ).lower()
@@ -247,7 +243,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
return ((get_value(0) > get_value(1)) ^ order) * 2 - 1 return ((get_value(0) > get_value(1)) ^ order) * 2 - 1
def navigate(self, next_page: Gtk.Widget) -> None: def navigate(self, next_page):
levels = (self.library_view, self.hidden_library_view, self.details_view) levels = (self.library_view, self.hidden_library_view, self.details_view)
self.stack.set_transition_type( self.stack.set_transition_type(
Gtk.StackTransitionType.UNDER_RIGHT Gtk.StackTransitionType.UNDER_RIGHT
@@ -264,13 +260,13 @@ class CartridgesWindow(Adw.ApplicationWindow):
self.stack.set_visible_child(next_page) self.stack.set_visible_child(next_page)
def on_go_back_action(self, *_args: Any) -> None: def on_go_back_action(self, *_args):
if self.stack.get_visible_child() == self.hidden_library_view: if self.stack.get_visible_child() == self.hidden_library_view:
self.navigate(self.library_view) self.navigate(self.library_view)
elif self.stack.get_visible_child() == self.details_view: elif self.stack.get_visible_child() == self.details_view:
self.on_go_to_parent_action() self.on_go_to_parent_action()
def on_go_to_parent_action(self, *_args: Any) -> None: def on_go_to_parent_action(self, *_args):
if self.stack.get_visible_child() == self.details_view: if self.stack.get_visible_child() == self.details_view:
self.navigate( self.navigate(
self.hidden_library_view self.hidden_library_view
@@ -278,20 +274,20 @@ class CartridgesWindow(Adw.ApplicationWindow):
else self.library_view else self.library_view
) )
def on_go_home_action(self, *_args: Any) -> None: def on_go_home_action(self, *_args):
self.navigate(self.library_view) self.navigate(self.library_view)
def on_show_hidden_action(self, *_args: Any) -> None: def on_show_hidden_action(self, *_args):
self.navigate(self.hidden_library_view) self.navigate(self.hidden_library_view)
def on_sort_action(self, action: Gio.SimpleAction, state: GLib.Variant) -> None: def on_sort_action(self, action, state):
action.set_state(state) action.set_state(state)
self.sort_state = str(state).strip("'") self.sort_state = str(state).strip("'")
self.library.invalidate_sort() self.library.invalidate_sort()
shared.state_schema.set_string("sort-mode", self.sort_state) shared.state_schema.set_string("sort-mode", self.sort_state)
def on_toggle_search_action(self, *_args: Any) -> None: def on_toggle_search_action(self, *_args):
if self.stack.get_visible_child() == self.library_view: if self.stack.get_visible_child() == self.library_view:
search_bar = self.search_bar search_bar = self.search_bar
search_entry = self.search_entry search_entry = self.search_entry
@@ -308,7 +304,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
search_entry.set_text("") search_entry.set_text("")
def on_escape_action(self, *_args: Any) -> None: def on_escape_action(self, *_args):
if ( if (
self.get_focus() == self.search_entry.get_focus_child() self.get_focus() == self.search_entry.get_focus_child()
or self.hidden_search_entry.get_focus_child() or self.hidden_search_entry.get_focus_child()
@@ -317,7 +313,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
else: else:
self.on_go_back_action() self.on_go_back_action()
def show_details_view_search(self, widget: Gtk.Widget) -> None: def show_details_view_search(self, widget):
library = ( library = (
self.hidden_library if widget == self.hidden_search_entry else self.library self.hidden_library if widget == self.hidden_search_entry else self.library
) )
@@ -333,39 +329,30 @@ class CartridgesWindow(Adw.ApplicationWindow):
index += 1 index += 1
def on_undo_action( def on_undo_action(self, _widget, game=None, undo=None):
self, _widget: Any, game: Optional[Game] = None, undo: Optional[str] = None
) -> None:
if not game: # If the action was activated via Ctrl + Z if not game: # If the action was activated via Ctrl + Z
if shared.importer and (
shared.importer.imported_game_ids or shared.importer.removed_game_ids
):
shared.importer.undo_import()
return
try: try:
game = tuple(self.toasts.keys())[-1][0] game = tuple(self.toasts.keys())[-1][0]
undo = tuple(self.toasts.keys())[-1][1] undo = tuple(self.toasts.keys())[-1][1]
except IndexError: except IndexError:
return return
if game: if undo == "hide":
if undo == "hide": game.toggle_hidden(False)
game.toggle_hidden(False)
elif undo == "remove": elif undo == "remove":
game.removed = False game.removed = False
game.save() game.save()
game.update() game.update()
self.toasts[(game, undo)].dismiss() self.toasts[(game, undo)].dismiss()
self.toasts.pop((game, undo)) self.toasts.pop((game, undo))
def on_open_menu_action(self, *_args: Any) -> None: def on_open_menu_action(self, *_args):
if self.stack.get_visible_child() == self.library_view: if self.stack.get_visible_child() == self.library_view:
self.primary_menu_button.popup() self.primary_menu_button.popup()
elif self.stack.get_visible_child() == self.hidden_library_view: elif self.stack.get_visible_child() == self.hidden_library_view:
self.hidden_primary_menu_button.popup() self.hidden_primary_menu_button.popup()
def on_close_action(self, *_args: Any) -> None: def on_close_action(self, *_args):
self.close() self.close()