Compare commits
63 Commits
v2.1.1
...
retroarch-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
77dfc366b1 | ||
|
|
37f838e4a2 | ||
|
|
a39303603c | ||
|
|
195f7dbb7e | ||
|
|
191beed12e | ||
|
|
208eae75c1 | ||
|
|
f3c590b033 | ||
|
|
a0c736b7cf | ||
|
|
0d32414f1e | ||
|
|
eeb18eb017 | ||
|
|
a3aa7f9ccf | ||
|
|
0e2918cfe8 | ||
|
|
8dc96b53b2 | ||
|
|
0d6432388c | ||
|
|
73897a9ed3 | ||
|
|
f8037e2542 | ||
|
|
afb2d8b6d3 | ||
|
|
dbb6076fdc | ||
|
|
16d6a026e5 | ||
|
|
cb9513ff13 | ||
|
|
b466eb7ab2 | ||
|
|
5551756111 | ||
|
|
9b97e8c355 | ||
|
|
5abf3f300d | ||
|
|
8ca264ff95 | ||
|
|
1c2c844f89 | ||
|
|
86ac95641c | ||
|
|
c2c998adcd | ||
|
|
0599a61057 | ||
|
|
07b78bcdbd | ||
|
|
78199267b3 | ||
|
|
0098669ab6 | ||
|
|
fefa9d27bd | ||
|
|
fe07f4f571 | ||
|
|
70d8d91b53 | ||
|
|
675359ee58 | ||
|
|
f43d8ff907 | ||
|
|
e90215bf66 | ||
|
|
2e844b2d06 | ||
|
|
8de7226a2f | ||
|
|
7598f1ea71 | ||
|
|
417a02e8b6 | ||
|
|
1aff1347e3 | ||
|
|
e4dc1253ae | ||
|
|
311ed3b09c | ||
|
|
8eca19d9a1 | ||
|
|
8bd96fdb38 | ||
|
|
55cd590424 | ||
|
|
386120a505 | ||
|
|
faee57a42a | ||
|
|
0a2051f5c7 | ||
|
|
0865d4e133 | ||
|
|
9a7875eb87 | ||
|
|
6b26076b92 | ||
|
|
19b0737715 | ||
|
|
06de79ad54 | ||
|
|
9ffbcc73ae | ||
|
|
5a89f8a542 | ||
|
|
f9cfc311fa | ||
|
|
45884d5c11 | ||
|
|
86a34f1596 | ||
|
|
9ccb315a2d | ||
|
|
c96b64f72e |
@@ -2,7 +2,7 @@ using Gtk 4.0;
|
|||||||
using Adw 1;
|
using Adw 1;
|
||||||
|
|
||||||
template $DetailsWindow : Adw.Window {
|
template $DetailsWindow : Adw.Window {
|
||||||
default-width: 500;
|
default-width: 480; // Same as Nautilus' properties window
|
||||||
default-height: -1;
|
default-height: -1;
|
||||||
modal: true;
|
modal: true;
|
||||||
|
|
||||||
@@ -97,39 +97,26 @@ template $DetailsWindow : Adw.Window {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Adw.PreferencesGroup title_group {
|
Adw.PreferencesGroup {
|
||||||
title: _("Title");
|
Adw.EntryRow name {
|
||||||
description: _("The title of the game");
|
title: _("Title");
|
||||||
|
}
|
||||||
Entry name {
|
Adw.EntryRow developer {
|
||||||
accessibility {
|
title: _("Developer (optional)");
|
||||||
label: _("Title");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Adw.PreferencesGroup {
|
||||||
|
Adw.EntryRow executable {
|
||||||
|
title: _("Executable");
|
||||||
|
|
||||||
Adw.PreferencesGroup developer_group {
|
[suffix]
|
||||||
title: _("Developer");
|
Gtk.MenuButton exec_info_button {
|
||||||
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;
|
||||||
@@ -141,7 +128,6 @@ template $DetailsWindow : Adw.Window {
|
|||||||
margin-bottom: 6;
|
margin-bottom: 6;
|
||||||
margin-start: 6;
|
margin-start: 6;
|
||||||
margin-end: 6;
|
margin-end: 6;
|
||||||
selectable: true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -150,10 +136,6 @@ template $DetailsWindow : Adw.Window {
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
Entry executable {
|
|
||||||
accessibility {
|
|
||||||
label: _("Executable");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,6 +85,19 @@ 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");
|
||||||
|
|
||||||
@@ -235,6 +248,20 @@ 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;
|
||||||
|
|||||||
@@ -269,6 +269,7 @@ template $CartridgesWindow : Adw.ApplicationWindow {
|
|||||||
tightening-threshold: 500;
|
tightening-threshold: 500;
|
||||||
|
|
||||||
SearchEntry search_entry {
|
SearchEntry search_entry {
|
||||||
|
placeholder-text: _("Search games");
|
||||||
hexpand: true;
|
hexpand: true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -335,6 +336,7 @@ 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
Keywords=gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;retroarch;
|
||||||
StartupNotify=true
|
StartupNotify=true
|
||||||
|
|||||||
@@ -10,6 +10,9 @@
|
|||||||
<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>
|
||||||
@@ -67,6 +70,12 @@
|
|||||||
<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>
|
||||||
@@ -113,4 +122,4 @@
|
|||||||
<default>"[]"</default>
|
<default>"[]"</default>
|
||||||
</key>
|
</key>
|
||||||
</schema>
|
</schema>
|
||||||
</schemalist>
|
</schemalist>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
"--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" : [
|
||||||
|
|||||||
@@ -15,5 +15,7 @@ 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
|
||||||
@@ -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-07-25 20:33+0200\n"
|
"POT-Creation-Date: 2023-08-16 11:06+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=CHARSET\n"
|
"Content-Type: text/plain; charset=UTF-8\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:170
|
#: src/main.py:169
|
||||||
msgid "Cartridges"
|
msgid "Cartridges"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -33,7 +33,8 @@ msgid "Launch all your games"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/hu.kramo.Cartridges.desktop.in:11
|
#: data/hu.kramo.Cartridges.desktop.in:11
|
||||||
msgid "gaming;launcher;steam;lutris;heroic;bottles;itch;flatpak;legendary;"
|
msgid ""
|
||||||
|
"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
|
||||||
@@ -48,16 +49,18 @@ msgstr ""
|
|||||||
msgid "Library"
|
msgid "Library"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/hu.kramo.Cartridges.metainfo.xml.in:34 src/details_window.py:67
|
#: data/hu.kramo.Cartridges.metainfo.xml.in:34
|
||||||
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:416
|
#: data/hu.kramo.Cartridges.metainfo.xml.in:42 data/gtk/window.blp:418
|
||||||
#: src/details_window.py:241
|
#: src/details_window.py:241 src/importer/importer.py:292
|
||||||
|
#: src/importer/importer.py:342
|
||||||
msgid "Preferences"
|
msgid "Preferences"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -73,32 +76,19 @@ msgstr ""
|
|||||||
msgid "Delete Cover"
|
msgid "Delete Cover"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/details-window.blp:101 data/gtk/details-window.blp:106
|
#: data/gtk/details-window.blp:102 data/gtk/game.blp:80
|
||||||
#: data/gtk/game.blp:80
|
|
||||||
msgid "Title"
|
msgid "Title"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/details-window.blp:102
|
#: data/gtk/details-window.blp:105
|
||||||
msgid "The title of the game"
|
msgid "Developer (optional)"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/details-window.blp:112 data/gtk/details-window.blp:117
|
#: data/gtk/details-window.blp:110
|
||||||
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:124
|
#: data/gtk/details-window.blp:116
|
||||||
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 ""
|
||||||
|
|
||||||
@@ -128,7 +118,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:323
|
#: data/gtk/window.blp:324
|
||||||
msgid "Search"
|
msgid "Search"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -140,7 +130,8 @@ msgstr ""
|
|||||||
msgid "Shortcuts"
|
msgid "Shortcuts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/help-overlay.blp:34 src/game.py:102 src/preferences.py:113
|
#: data/gtk/help-overlay.blp:34 src/game.py:103 src/preferences.py:120
|
||||||
|
#: src/importer/importer.py:366
|
||||||
msgid "Undo"
|
msgid "Undo"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -168,7 +159,8 @@ msgstr ""
|
|||||||
msgid "Remove game"
|
msgid "Remove game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:277
|
#: data/gtk/preferences.blp:13 data/gtk/preferences.blp:89
|
||||||
|
#: data/gtk/preferences.blp:304
|
||||||
msgid "Behavior"
|
msgid "Behavior"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -204,106 +196,114 @@ 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:442
|
#: data/gtk/preferences.blp:85 data/gtk/window.blp:27 data/gtk/window.blp:444
|
||||||
msgid "Import"
|
msgid "Import"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:89
|
#: data/gtk/preferences.blp:92
|
||||||
|
msgid "Remove Uninstalled Games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: data/gtk/preferences.blp:102
|
||||||
msgid "Sources"
|
msgid "Sources"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:92
|
#: data/gtk/preferences.blp:105
|
||||||
msgid "Steam"
|
msgid "Steam"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:96 data/gtk/preferences.blp:110
|
#: data/gtk/preferences.blp:109 data/gtk/preferences.blp:123
|
||||||
#: data/gtk/preferences.blp:151 data/gtk/preferences.blp:201
|
#: data/gtk/preferences.blp:164 data/gtk/preferences.blp:214
|
||||||
#: data/gtk/preferences.blp:215 data/gtk/preferences.blp:229
|
#: data/gtk/preferences.blp:228 data/gtk/preferences.blp:242
|
||||||
#: data/gtk/preferences.blp:243
|
#: data/gtk/preferences.blp:256 data/gtk/preferences.blp:270
|
||||||
msgid "Install Location"
|
msgid "Install Location"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:106
|
#: data/gtk/preferences.blp:119
|
||||||
msgid "Lutris"
|
msgid "Lutris"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:119
|
#: data/gtk/preferences.blp:132
|
||||||
msgid "Cache Location"
|
msgid "Cache Location"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:128
|
#: data/gtk/preferences.blp:141
|
||||||
msgid "Import Steam Games"
|
msgid "Import Steam Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:137
|
#: data/gtk/preferences.blp:150
|
||||||
msgid "Import Flatpak Games"
|
msgid "Import Flatpak Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:147
|
#: data/gtk/preferences.blp:160
|
||||||
msgid "Heroic"
|
msgid "Heroic"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:160
|
#: data/gtk/preferences.blp:173
|
||||||
msgid "Import Epic Games"
|
msgid "Import Epic Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:169
|
#: data/gtk/preferences.blp:182
|
||||||
msgid "Import GOG Games"
|
msgid "Import GOG Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:178
|
#: data/gtk/preferences.blp:191
|
||||||
msgid "Import Amazon Games"
|
msgid "Import Amazon Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:187
|
#: data/gtk/preferences.blp:200
|
||||||
msgid "Import Sideloaded Games"
|
msgid "Import Sideloaded Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:197
|
#: data/gtk/preferences.blp:210
|
||||||
msgid "Bottles"
|
msgid "Bottles"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:211
|
#: data/gtk/preferences.blp:224
|
||||||
msgid "itch"
|
msgid "itch"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:225
|
#: data/gtk/preferences.blp:238
|
||||||
msgid "Legendary"
|
msgid "Legendary"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:239
|
#: data/gtk/preferences.blp:252
|
||||||
|
msgid "RetroArch"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: data/gtk/preferences.blp:266
|
||||||
msgid "Flatpak"
|
msgid "Flatpak"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:252
|
#: data/gtk/preferences.blp:279
|
||||||
msgid "Import Game Launchers"
|
msgid "Import Game Launchers"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:265
|
#: data/gtk/preferences.blp:292
|
||||||
msgid "SteamGridDB"
|
msgid "SteamGridDB"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:269
|
#: data/gtk/preferences.blp:296
|
||||||
msgid "Authentication"
|
msgid "Authentication"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:272
|
#: data/gtk/preferences.blp:299
|
||||||
msgid "API Key"
|
msgid "API Key"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:280
|
#: data/gtk/preferences.blp:307
|
||||||
msgid "Use SteamGridDB"
|
msgid "Use SteamGridDB"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:281
|
#: data/gtk/preferences.blp:308
|
||||||
msgid "Download images when adding or importing games"
|
msgid "Download images when adding or importing games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:290
|
#: data/gtk/preferences.blp:317
|
||||||
msgid "Prefer Over Official Images"
|
msgid "Prefer Over Official Images"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/preferences.blp:299
|
#: data/gtk/preferences.blp:326
|
||||||
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:304
|
#: data/gtk/window.blp:64 data/gtk/window.blp:305
|
||||||
msgid "Back"
|
msgid "Back"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -343,51 +343,59 @@ msgstr ""
|
|||||||
msgid "Play"
|
msgid "Play"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:243 data/gtk/window.blp:435
|
#: data/gtk/window.blp:243 data/gtk/window.blp:437
|
||||||
msgid "Add Game"
|
msgid "Add Game"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:250 data/gtk/window.blp:316
|
#: data/gtk/window.blp:250 data/gtk/window.blp:317
|
||||||
msgid "Main Menu"
|
msgid "Main Menu"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:311
|
#: data/gtk/window.blp:272
|
||||||
|
msgid "Search games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: data/gtk/window.blp:312
|
||||||
msgid "Hidden Games"
|
msgid "Hidden Games"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:374
|
#: data/gtk/window.blp:339
|
||||||
|
msgid "Search hidden games"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: data/gtk/window.blp:376
|
||||||
msgid "Sort"
|
msgid "Sort"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:377
|
#: data/gtk/window.blp:379
|
||||||
msgid "A-Z"
|
msgid "A-Z"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:383
|
#: data/gtk/window.blp:385
|
||||||
msgid "Z-A"
|
msgid "Z-A"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:389
|
#: data/gtk/window.blp:391
|
||||||
msgid "Newest"
|
msgid "Newest"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:395
|
#: data/gtk/window.blp:397
|
||||||
msgid "Oldest"
|
msgid "Oldest"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:401
|
#: data/gtk/window.blp:403
|
||||||
msgid "Last Played"
|
msgid "Last Played"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:408
|
#: data/gtk/window.blp:410
|
||||||
msgid "Show Hidden"
|
msgid "Show Hidden"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:421
|
#: data/gtk/window.blp:423
|
||||||
msgid "Keyboard Shortcuts"
|
msgid "Keyboard Shortcuts"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: data/gtk/window.blp:426
|
#: data/gtk/window.blp:428
|
||||||
msgid "About Cartridges"
|
msgid "About Cartridges"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
@@ -419,7 +427,7 @@ msgid "Add New Game"
|
|||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/details_window.py:79
|
#: src/details_window.py:79
|
||||||
msgid "Confirm"
|
msgid "Add"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#. Translate this string as you would translate "file"
|
#. Translate this string as you would translate "file"
|
||||||
@@ -472,71 +480,103 @@ 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:138
|
#: src/game.py:139
|
||||||
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:152
|
#: src/game.py:153
|
||||||
msgid "{} hidden"
|
msgid "{} hidden"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/game.py:152
|
#: src/game.py:153
|
||||||
msgid "{} unhidden"
|
msgid "{} unhidden"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/game.py:169
|
#. The variable is the title of the game
|
||||||
|
#. 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:112
|
#: src/preferences.py:119
|
||||||
msgid "All games removed"
|
msgid "All games removed"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/preferences.py:160
|
#: src/preferences.py:168
|
||||||
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:285
|
#: src/preferences.py:294
|
||||||
msgid "Installation Not Found"
|
msgid "Installation Not Found"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/preferences.py:287
|
#: src/preferences.py:296
|
||||||
msgid "Select a valid directory."
|
msgid "Select a valid directory."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/preferences.py:349
|
#: src/preferences.py:351
|
||||||
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/preferences.py:353
|
#: src/importer/sources/location.py:33
|
||||||
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/preferences.py:356
|
#: src/importer/sources/location.py:35
|
||||||
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/preferences.py:359
|
#: src/importer/sources/location.py:37
|
||||||
msgid "Select the {} data directory."
|
msgid "Select the {} data directory."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/preferences.py:365
|
#: src/store/managers/sgdb_manager.py:46
|
||||||
msgid "Set Location"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/utils/create_dialog.py:25
|
|
||||||
msgid "Dismiss"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: src/store/managers/sgdb_manager.py:47
|
|
||||||
msgid "Couldn't Authenticate SteamGridDB"
|
msgid "Couldn't Authenticate SteamGridDB"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: src/store/managers/sgdb_manager.py:48
|
#: src/store/managers/sgdb_manager.py:47
|
||||||
msgid "Verify your API key in preferences"
|
msgid "Verify your API key in preferences"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|||||||
@@ -19,6 +19,7 @@
|
|||||||
|
|
||||||
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
|
||||||
@@ -52,19 +53,18 @@ class DetailsWindow(Adw.Window):
|
|||||||
|
|
||||||
apply_button = Gtk.Template.Child()
|
apply_button = Gtk.Template.Child()
|
||||||
|
|
||||||
cover_changed = False
|
cover_changed: bool = False
|
||||||
|
|
||||||
def __init__(self, game=None, **kwargs):
|
def __init__(self, game: Optional[Game] = None, **kwargs: Any):
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.win = shared.win
|
self.game: Game = game
|
||||||
self.game = game
|
self.game_cover: GameCover = GameCover({self.cover})
|
||||||
self.game_cover = GameCover({self.cover})
|
|
||||||
|
|
||||||
self.set_transient_for(self.win)
|
self.set_transient_for(shared.win)
|
||||||
|
|
||||||
if self.game:
|
if self.game:
|
||||||
self.set_title(_("Edit Game Details"))
|
self.set_title(_("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(_("Confirm"))
|
self.apply_button.set_label(_("Add"))
|
||||||
|
|
||||||
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,29 +114,36 @@ class DetailsWindow(Adw.Window):
|
|||||||
|
|
||||||
self.exec_info_label.set_label(exec_info_text)
|
self.exec_info_label.set_label(exec_info_text)
|
||||||
|
|
||||||
def clear_info_selection(*_args):
|
self.exec_info_popover.update_property(
|
||||||
self.exec_info_label.select_region(-1, -1)
|
(Gtk.AccessibleProperty.LABEL,),
|
||||||
|
(
|
||||||
|
exec_info_text.replace("<tt>", "").replace("</tt>", ""),
|
||||||
|
), # Remove formatting, else the screen reader reads it
|
||||||
|
)
|
||||||
|
|
||||||
self.exec_info_popover.connect("show", clear_info_selection)
|
def set_exec_info_a11y_label(*_args: Any) -> None:
|
||||||
|
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("activate", self.focus_executable)
|
self.name.connect("entry-activated", self.focus_executable)
|
||||||
self.developer.connect("activate", self.focus_executable)
|
self.developer.connect("entry-activated", self.focus_executable)
|
||||||
self.executable.connect("activate", self.apply_preferences)
|
self.executable.connect("entry-activated", self.apply_preferences)
|
||||||
|
|
||||||
self.set_focus(self.name)
|
self.set_focus(self.name)
|
||||||
self.present()
|
self.present()
|
||||||
|
|
||||||
def delete_pixbuf(self, *_args):
|
def delete_pixbuf(self, *_args: Any) -> None:
|
||||||
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):
|
def apply_preferences(self, *_args: Any) -> None:
|
||||||
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()
|
||||||
@@ -196,10 +203,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 self.win.game_covers.keys():
|
if self.game.game_id in shared.win.game_covers.keys():
|
||||||
self.win.game_covers[self.game.game_id].animation = None
|
shared.win.game_covers[self.game.game_id].animation = None
|
||||||
|
|
||||||
self.win.game_covers[self.game.game_id] = self.game_cover
|
shared.win.game_covers[self.game.game_id] = self.game_cover
|
||||||
|
|
||||||
if self.cover_changed:
|
if self.cover_changed:
|
||||||
save_cover(
|
save_cover(
|
||||||
@@ -222,9 +229,9 @@ class DetailsWindow(Adw.Window):
|
|||||||
self.game_cover.pictures.remove(self.cover)
|
self.game_cover.pictures.remove(self.cover)
|
||||||
|
|
||||||
self.close()
|
self.close()
|
||||||
self.win.show_details_view(self.game)
|
shared.win.show_details_view(self.game)
|
||||||
|
|
||||||
def update_cover_callback(self, manager: SGDBManager):
|
def update_cover_callback(self, manager: SGDBManager) -> None:
|
||||||
# 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()
|
||||||
@@ -241,25 +248,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, response):
|
def update_cover_error_response(self, _widget: Any, response: str) -> None:
|
||||||
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):
|
def focus_executable(self, *_args: Any) -> None:
|
||||||
self.set_focus(self.executable)
|
self.set_focus(self.executable)
|
||||||
|
|
||||||
def toggle_loading(self):
|
def toggle_loading(self) -> None:
|
||||||
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, result, *_args):
|
def set_cover(self, _source: Any, result: Gio.Task, *_args: Any) -> None:
|
||||||
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():
|
def resize() -> None:
|
||||||
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)
|
||||||
@@ -269,5 +276,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):
|
def choose_cover(self, *_args: Any) -> None:
|
||||||
self.file_dialog.open(self, None, self.set_cover)
|
self.file_dialog.open(self, None, self.set_cover)
|
||||||
|
|||||||
@@ -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] = None
|
errors: list[Exception]
|
||||||
errors_lock: Lock = None
|
errors_lock: Lock
|
||||||
|
|
||||||
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):
|
def report_error(self, error: Exception) -> None:
|
||||||
"""Report an error"""
|
"""Report an error"""
|
||||||
with self.errors_lock:
|
with self.errors_lock:
|
||||||
self.errors.append(error)
|
self.errors.append(error)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
from typing import Iterable
|
from typing import Iterable, Optional
|
||||||
|
|
||||||
|
|
||||||
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: Iterable[str] = None,
|
title_args: Optional[Iterable[str]] = None,
|
||||||
subtitle_args: Iterable[str] = None,
|
subtitle_args: Optional[Iterable[str]] = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a friendly error
|
"""Create a friendly error
|
||||||
|
|
||||||
|
|||||||
78
src/game.py
78
src/game.py
@@ -23,10 +23,12 @@ 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
|
||||||
@@ -45,23 +47,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 = 0
|
loading: int = 0
|
||||||
filtered = False
|
filtered: bool = False
|
||||||
|
|
||||||
added = None
|
added: int
|
||||||
executable = None
|
executable: str
|
||||||
game_id = None
|
game_id: str
|
||||||
source = None
|
source: str
|
||||||
hidden = False
|
hidden: bool = False
|
||||||
last_played = 0
|
last_played: int = 0
|
||||||
name = None
|
name: str
|
||||||
developer = None
|
developer: Optional[str] = None
|
||||||
removed = False
|
removed: bool = False
|
||||||
blacklisted = False
|
blacklisted: bool = False
|
||||||
game_cover = None
|
game_cover: GameCover = None
|
||||||
version = 0
|
version: int = 0
|
||||||
|
|
||||||
def __init__(self, data, **kwargs):
|
def __init__(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.win = shared.win
|
self.win = shared.win
|
||||||
@@ -69,6 +71,7 @@ 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()
|
||||||
|
|
||||||
@@ -81,20 +84,20 @@ class Game(Gtk.Box):
|
|||||||
|
|
||||||
shared.schema.connect("changed", self.schema_changed)
|
shared.schema.connect("changed", self.schema_changed)
|
||||||
|
|
||||||
def update_values(self, data):
|
def update_values(self, data: dict[str, Any]) -> None:
|
||||||
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):
|
def update(self) -> None:
|
||||||
self.emit("update-ready", {})
|
self.emit("update-ready", {})
|
||||||
|
|
||||||
def save(self):
|
def save(self) -> None:
|
||||||
self.emit("save-ready", {})
|
self.emit("save-ready", {})
|
||||||
|
|
||||||
def create_toast(self, title, action=None):
|
def create_toast(self, title: str, action: Optional[str] = None) -> 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)
|
||||||
|
|
||||||
@@ -110,7 +113,7 @@ class Game(Gtk.Box):
|
|||||||
|
|
||||||
self.win.toast_overlay.add_toast(toast)
|
self.win.toast_overlay.add_toast(toast)
|
||||||
|
|
||||||
def launch(self):
|
def launch(self) -> None:
|
||||||
self.last_played = int(time())
|
self.last_played = int(time())
|
||||||
self.save()
|
self.save()
|
||||||
self.update()
|
self.update()
|
||||||
@@ -125,10 +128,10 @@ class Game(Gtk.Box):
|
|||||||
# pylint: disable=consider-using-with
|
# pylint: disable=consider-using-with
|
||||||
subprocess.Popen(
|
subprocess.Popen(
|
||||||
args,
|
args,
|
||||||
cwd=Path.home(),
|
cwd=shared.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,
|
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
if shared.schema.get_boolean("exit-after-launch"):
|
if shared.schema.get_boolean("exit-after-launch"):
|
||||||
@@ -137,7 +140,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=True):
|
def toggle_hidden(self, toast: bool = True) -> None:
|
||||||
self.hidden = not self.hidden
|
self.hidden = not self.hidden
|
||||||
self.save()
|
self.save()
|
||||||
|
|
||||||
@@ -155,7 +158,7 @@ class Game(Gtk.Box):
|
|||||||
"hide",
|
"hide",
|
||||||
)
|
)
|
||||||
|
|
||||||
def remove_game(self):
|
def remove_game(self) -> None:
|
||||||
# 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()
|
||||||
@@ -164,55 +167,58 @@ 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(
|
||||||
_("{} removed").format(GLib.markup_escape_text(self.name)), "remove"
|
# The variable is the title of the game
|
||||||
|
_("{} removed").format(GLib.markup_escape_text(self.name)),
|
||||||
|
"remove",
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_loading(self, state):
|
def set_loading(self, state: int) -> None:
|
||||||
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):
|
def get_cover_path(self) -> Optional[Path]:
|
||||||
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
|
return cover_path # type: ignore
|
||||||
|
|
||||||
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
|
return cover_path # type: ignore
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def toggle_play(self, _widget, _prop1, _prop2, state=True):
|
def toggle_play(
|
||||||
|
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, button):
|
def main_button_clicked(self, _widget: Any, button: bool) -> None:
|
||||||
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):
|
def set_play_icon(self) -> None:
|
||||||
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, key):
|
def schema_changed(self, _settings: Any, key: str) -> None:
|
||||||
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) -> None:
|
def update_ready(self, _additional_data): # type: ignore
|
||||||
"""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) -> None:
|
def save_ready(self, _additional_data): # type: ignore
|
||||||
"""Signal emitted when the game needs saving"""
|
"""Signal emitted when the game needs saving"""
|
||||||
|
|||||||
@@ -17,19 +17,22 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
|
from pathlib import Path
|
||||||
|
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 = None
|
texture: Optional[Gdk.Texture]
|
||||||
blurred = None
|
blurred: Optional[Gdk.Texture]
|
||||||
luminance = None
|
luminance: Optional[tuple[float, float]]
|
||||||
path = None
|
path: Optional[Path]
|
||||||
animation = None
|
animation: Optional[GdkPixbuf.PixbufAnimation]
|
||||||
anim_iter = None
|
anim_iter: Optional[GdkPixbuf.PixbufAnimationIter]
|
||||||
|
|
||||||
placeholder = Gdk.Texture.new_from_resource(
|
placeholder = Gdk.Texture.new_from_resource(
|
||||||
shared.PREFIX + "/library_placeholder.svg"
|
shared.PREFIX + "/library_placeholder.svg"
|
||||||
@@ -38,21 +41,21 @@ class GameCover:
|
|||||||
shared.PREFIX + "/library_placeholder_small.svg"
|
shared.PREFIX + "/library_placeholder_small.svg"
|
||||||
)
|
)
|
||||||
|
|
||||||
def __init__(self, pictures, path=None):
|
def __init__(self, pictures: set[Gtk.Picture], path: Optional[Path] = None) -> 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):
|
def create_func(self, path: Optional[Path]) -> Callable:
|
||||||
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, *_args):
|
def wrapper(task: Gio.Task, *_args: Any) -> None:
|
||||||
self.update_animation((task, self.animation))
|
self.update_animation((task, self.animation))
|
||||||
|
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
def new_cover(self, path=None):
|
def new_cover(self, path: Optional[Path] = None) -> None:
|
||||||
self.animation = None
|
self.animation = None
|
||||||
self.texture = None
|
self.texture = None
|
||||||
self.blurred = None
|
self.blurred = None
|
||||||
@@ -69,14 +72,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):
|
def get_texture(self) -> Gdk.Texture:
|
||||||
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):
|
def get_blurred(self) -> Gdk.Texture:
|
||||||
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:
|
||||||
@@ -94,24 +97,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):
|
def add_picture(self, picture: Gtk.Picture) -> None:
|
||||||
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):
|
def set_texture(self, texture: Gdk.Texture) -> None:
|
||||||
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()
|
||||||
)
|
)
|
||||||
@@ -121,13 +124,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):
|
def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None:
|
||||||
if self.animation == data[1]:
|
if self.animation == data[1]:
|
||||||
self.anim_iter.advance()
|
self.anim_iter.advance() # type: ignore
|
||||||
|
|
||||||
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf()))
|
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore
|
||||||
|
|
||||||
delay_time = self.anim_iter.get_delay_time()
|
delay_time = self.anim_iter.get_delay_time() # type: ignore
|
||||||
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,
|
||||||
|
|||||||
@@ -19,6 +19,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, Optional
|
||||||
|
|
||||||
from gi.repository import Adw, GLib, Gtk
|
from gi.repository import Adw, GLib, Gtk
|
||||||
|
|
||||||
@@ -37,41 +38,49 @@ 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 = None
|
progressbar: Gtk.ProgressBar
|
||||||
import_statuspage = None
|
import_statuspage: Adw.StatusPage
|
||||||
import_dialog = None
|
import_dialog: Adw.MessageDialog
|
||||||
summary_toast = None
|
summary_toast: Adw.Toast
|
||||||
|
|
||||||
sources: set[Source] = None
|
sources: set[Source]
|
||||||
|
|
||||||
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] = None
|
game_pipelines: set[Pipeline]
|
||||||
|
|
||||||
def __init__(self):
|
removed_game_ids: set[str] = set()
|
||||||
|
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):
|
def n_games_added(self) -> int:
|
||||||
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):
|
def pipelines_progress(self) -> float:
|
||||||
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
|
return progress # type: ignore
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def sources_progress(self):
|
def sources_progress(self) -> float:
|
||||||
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:
|
||||||
@@ -79,16 +88,16 @@ class Importer(ErrorProducer):
|
|||||||
return progress
|
return progress
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def finished(self):
|
def finished(self) -> bool:
|
||||||
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):
|
def add_source(self, source: Source) -> None:
|
||||||
self.sources.add(source)
|
self.sources.add(source)
|
||||||
|
|
||||||
def run(self):
|
def run(self) -> None:
|
||||||
"""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)
|
||||||
@@ -113,7 +122,7 @@ class Importer(ErrorProducer):
|
|||||||
|
|
||||||
self.progress_changed_callback()
|
self.progress_changed_callback()
|
||||||
|
|
||||||
def create_dialog(self):
|
def create_dialog(self) -> None:
|
||||||
"""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(
|
||||||
@@ -130,7 +139,9 @@ class Importer(ErrorProducer):
|
|||||||
)
|
)
|
||||||
self.import_dialog.present()
|
self.import_dialog.present()
|
||||||
|
|
||||||
def source_task_thread_func(self, _task, _obj, data, _cancellable):
|
def source_task_thread_func(
|
||||||
|
self, _task: Any, _obj: Any, data: tuple, _cancellable: Any
|
||||||
|
) -> None:
|
||||||
"""Source import task code"""
|
"""Source import task code"""
|
||||||
|
|
||||||
source: Source
|
source: Source
|
||||||
@@ -184,27 +195,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):
|
def update_progressbar(self) -> None:
|
||||||
"""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, _result, data):
|
def source_callback(self, _obj: Any, _result: Any, data: tuple) -> None:
|
||||||
"""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):
|
def pipeline_advanced_callback(self, pipeline: Pipeline) -> None:
|
||||||
"""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):
|
def progress_changed_callback(self) -> None:
|
||||||
"""
|
"""
|
||||||
Callback called when the import process has progressed
|
Callback called when the import process has progressed
|
||||||
|
|
||||||
@@ -217,19 +228,47 @@ class Importer(ErrorProducer):
|
|||||||
if self.finished:
|
if self.finished:
|
||||||
self.import_callback()
|
self.import_callback()
|
||||||
|
|
||||||
def import_callback(self):
|
def remove_games(self) -> None:
|
||||||
|
"""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):
|
def create_error_dialog(self) -> None:
|
||||||
"""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: list[Exception] = []
|
errors = []
|
||||||
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())
|
||||||
@@ -277,41 +316,78 @@ class Importer(ErrorProducer):
|
|||||||
|
|
||||||
dialog.present()
|
dialog.present()
|
||||||
|
|
||||||
def create_summary_toast(self):
|
def undo_import(self, *_args: Any) -> None:
|
||||||
"""N games imported toast"""
|
for game_id in self.imported_game_ids:
|
||||||
|
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 self.n_games_added == 0:
|
if not self.n_games_added:
|
||||||
toast.set_title(_("No new games found"))
|
toast_title = _("No new games found")
|
||||||
toast.set_button_label(_("Preferences"))
|
|
||||||
toast.connect(
|
if not self.removed_game_ids:
|
||||||
"button-clicked",
|
toast.set_button_label(_("Preferences"))
|
||||||
self.dialog_response_callback,
|
toast.connect(
|
||||||
"open_preferences",
|
"button-clicked",
|
||||||
"import",
|
self.dialog_response_callback,
|
||||||
)
|
"open_preferences",
|
||||||
|
"import",
|
||||||
|
)
|
||||||
|
|
||||||
elif self.n_games_added == 1:
|
elif self.n_games_added == 1:
|
||||||
toast.set_title(_("1 game imported"))
|
toast_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.set_title(_("{} games imported").format(self.n_games_added))
|
toast_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(self, page=None, expander_row=None):
|
def open_preferences(
|
||||||
|
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, expander_row=expander_row
|
page_name=page_name, expander_row=expander_row
|
||||||
)
|
)
|
||||||
|
|
||||||
def timeout_toast(self, *_args):
|
def timeout_toast(self, *_args: Any) -> None:
|
||||||
"""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, response, *args):
|
def dialog_response_callback(self, _widget: Any, response: str, *args: Any) -> None:
|
||||||
"""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":
|
||||||
|
|||||||
@@ -90,18 +90,22 @@ 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(
|
|
||||||
schema_key="bottles-location",
|
def __init__(self) -> None:
|
||||||
candidates=(
|
super().__init__()
|
||||||
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
|
self.locations = BottlesLocations(
|
||||||
shared.data_dir / "bottles/",
|
Location(
|
||||||
shared.home / ".local" / "share" / "bottles",
|
schema_key="bottles-location",
|
||||||
),
|
candidates=(
|
||||||
paths={
|
shared.flatpak_dir / "com.usebottles.bottles" / "data" / "bottles",
|
||||||
"library.yml": LocationSubPath("library.yml"),
|
shared.data_dir / "bottles/",
|
||||||
"data.yml": LocationSubPath("data.yml"),
|
shared.home / ".local" / "share" / "bottles",
|
||||||
},
|
),
|
||||||
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
paths={
|
||||||
|
"library.yml": LocationSubPath("library.yml"),
|
||||||
|
"data.yml": LocationSubPath("data.yml"),
|
||||||
|
},
|
||||||
|
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -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 Source, SourceIterable
|
from src.importer.sources.source import ExecutableFormatSource, SourceIterable
|
||||||
|
|
||||||
|
|
||||||
class FlatpakSourceIterable(SourceIterable):
|
class FlatpakSourceIterable(SourceIterable):
|
||||||
@@ -116,7 +116,7 @@ class FlatpakLocations(NamedTuple):
|
|||||||
data: Location
|
data: Location
|
||||||
|
|
||||||
|
|
||||||
class FlatpakSource(Source):
|
class FlatpakSource(ExecutableFormatSource):
|
||||||
"""Generic Flatpak source"""
|
"""Generic Flatpak source"""
|
||||||
|
|
||||||
source_id = "flatpak"
|
source_id = "flatpak"
|
||||||
@@ -125,17 +125,21 @@ class FlatpakSource(Source):
|
|||||||
executable_format = "flatpak run {flatpak_id}"
|
executable_format = "flatpak run {flatpak_id}"
|
||||||
available_on = {"linux"}
|
available_on = {"linux"}
|
||||||
|
|
||||||
locations = FlatpakLocations(
|
locations: FlatpakLocations
|
||||||
Location(
|
|
||||||
schema_key="flatpak-location",
|
def __init__(self) -> None:
|
||||||
candidates=(
|
super().__init__()
|
||||||
"/var/lib/flatpak/",
|
self.locations = FlatpakLocations(
|
||||||
shared.data_dir / "flatpak",
|
Location(
|
||||||
),
|
schema_key="flatpak-location",
|
||||||
paths={
|
candidates=(
|
||||||
"applications": LocationSubPath("exports/share/applications", True),
|
"/var/lib/flatpak/",
|
||||||
"icons": LocationSubPath("exports/share/icons", True),
|
shared.data_dir / "flatpak",
|
||||||
},
|
),
|
||||||
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
paths={
|
||||||
|
"applications": LocationSubPath("exports/share/applications", True),
|
||||||
|
"icons": LocationSubPath("exports/share/icons", True),
|
||||||
|
},
|
||||||
|
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -97,6 +97,7 @@ class SubSourceIterable(Iterable):
|
|||||||
"""Build a Game from a Heroic library entry"""
|
"""Build a Game from a Heroic library entry"""
|
||||||
|
|
||||||
app_name = entry["app_name"]
|
app_name = entry["app_name"]
|
||||||
|
runner = entry["runner"]
|
||||||
|
|
||||||
# Build game
|
# Build game
|
||||||
values = {
|
values = {
|
||||||
@@ -107,7 +108,9 @@ 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(app_name=app_name),
|
"executable": self.source.executable_format.format(
|
||||||
|
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)
|
||||||
@@ -238,7 +241,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 = Path.home() / ".config"
|
path = shared.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)
|
||||||
@@ -359,30 +362,34 @@ class HeroicSource(URLExecutableSource):
|
|||||||
source_id = "heroic"
|
source_id = "heroic"
|
||||||
name = _("Heroic")
|
name = _("Heroic")
|
||||||
iterable_class = HeroicSourceIterable
|
iterable_class = HeroicSourceIterable
|
||||||
url_format = "heroic://launch/{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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -86,18 +86,22 @@ 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(
|
|
||||||
schema_key="itch-location",
|
def __init__(self) -> None:
|
||||||
candidates=(
|
super().__init__()
|
||||||
shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
|
self.locations = ItchLocations(
|
||||||
shared.config_dir / "itch",
|
Location(
|
||||||
shared.home / ".config" / "itch",
|
schema_key="itch-location",
|
||||||
shared.appdata_dir / "itch",
|
candidates=(
|
||||||
),
|
shared.flatpak_dir / "io.itch.itch" / "config" / "itch",
|
||||||
paths={
|
shared.config_dir / "itch",
|
||||||
"butler.db": LocationSubPath("db/butler.db"),
|
shared.home / ".config" / "itch",
|
||||||
},
|
shared.appdata_dir / "itch",
|
||||||
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
|
),
|
||||||
|
paths={
|
||||||
|
"butler.db": LocationSubPath("db/butler.db"),
|
||||||
|
},
|
||||||
|
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -26,7 +26,11 @@ 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 Source, SourceIterationResult, SourceIterable
|
from src.importer.sources.source import (
|
||||||
|
ExecutableFormatSource,
|
||||||
|
SourceIterationResult,
|
||||||
|
SourceIterable,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class LegendarySourceIterable(SourceIterable):
|
class LegendarySourceIterable(SourceIterable):
|
||||||
@@ -93,24 +97,28 @@ class LegendaryLocations(NamedTuple):
|
|||||||
config: Location
|
config: Location
|
||||||
|
|
||||||
|
|
||||||
class LegendarySource(Source):
|
class LegendarySource(ExecutableFormatSource):
|
||||||
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(
|
|
||||||
schema_key="legendary-location",
|
def __init__(self) -> None:
|
||||||
candidates=(
|
super().__init__()
|
||||||
shared.config_dir / "legendary",
|
self.locations = LegendaryLocations(
|
||||||
shared.home / ".config" / "legendary",
|
Location(
|
||||||
),
|
schema_key="legendary-location",
|
||||||
paths={
|
candidates=(
|
||||||
"installed.json": LocationSubPath("installed.json"),
|
shared.config_dir / "legendary",
|
||||||
"metadata": LocationSubPath("metadata", True),
|
shared.home / ".config" / "legendary",
|
||||||
},
|
),
|
||||||
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
|
paths={
|
||||||
|
"installed.json": LocationSubPath("installed.json"),
|
||||||
|
"metadata": LocationSubPath("metadata", True),
|
||||||
|
},
|
||||||
|
invalid_subtitle=Location.CONFIG_INVALID_SUBTITLE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
|
||||||
from typing import Mapping, Iterable, NamedTuple
|
|
||||||
from os import PathLike
|
from os import PathLike
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Iterable, Mapping, NamedTuple, Optional
|
||||||
|
|
||||||
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: Path = None
|
root: Optional[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 a UnresolvableLocationError"""
|
If none fits, raise an UnresolvableLocationError"""
|
||||||
|
|
||||||
if self.root is not None:
|
if self.root is not None:
|
||||||
return
|
return
|
||||||
@@ -94,7 +94,9 @@ 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):
|
def __getitem__(self, key: str) -> Optional[Path]:
|
||||||
"""Get the computed path from its key for the location"""
|
"""Get the computed path from its key for the location"""
|
||||||
self.resolve()
|
self.resolve()
|
||||||
return self.root / self.paths[key].segment
|
if self.root:
|
||||||
|
return self.root / self.paths[key].segment
|
||||||
|
return None
|
||||||
|
|||||||
@@ -100,33 +100,37 @@ 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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
256
src/importer/sources/retroarch_source.py
Normal file
256
src/importer/sources/retroarch_source.py
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# 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)
|
||||||
@@ -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, Generator, Collection
|
from typing import Any, Collection, Generator, Optional
|
||||||
|
|
||||||
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 = None | Game | tuple[Game, tuple[Any]]
|
SourceIterationResult = Optional[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" = None
|
source: "Source"
|
||||||
|
|
||||||
def __init__(self, source: "Source") -> None:
|
def __init__(self, source: "Source") -> None:
|
||||||
self.source = source
|
self.source = source
|
||||||
@@ -53,16 +53,19 @@ class Source(Iterable):
|
|||||||
|
|
||||||
source_id: str
|
source_id: str
|
||||||
name: str
|
name: str
|
||||||
variant: str = None
|
variant: Optional[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 is not None:
|
if self.variant:
|
||||||
full_name_ += f" ({self.variant})"
|
full_name_ += f" ({self.variant})"
|
||||||
return full_name_
|
return full_name_
|
||||||
|
|
||||||
@@ -72,29 +75,41 @@ class Source(Iterable):
|
|||||||
return self.source_id + "_{game_id}"
|
return self.source_id + "_{game_id}"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_available(self):
|
def is_available(self) -> bool:
|
||||||
return sys.platform in self.available_on
|
return sys.platform in self.available_on
|
||||||
|
|
||||||
@property
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def executable_format(self) -> str:
|
def make_executable(self, *args, **kwargs) -> str:
|
||||||
"""The executable format used to construct game executables"""
|
"""
|
||||||
|
Create a game executable command.
|
||||||
|
Should be implemented by child classes.
|
||||||
|
"""
|
||||||
|
|
||||||
def __iter__(self) -> Generator[SourceIterationResult, None, None]:
|
def __iter__(self) -> Generator[SourceIterationResult, None, None]:
|
||||||
"""
|
"""
|
||||||
Get an iterator for the source
|
Get an iterator for the source
|
||||||
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
|
:raises UnresolvableLocationError: Not iterable if any of the locations are unresolvable
|
||||||
"""
|
"""
|
||||||
for location_name in ("data", "cache", "config"):
|
for location in self.locations:
|
||||||
location = getattr(self, f"{location_name}_location", None)
|
|
||||||
if location is None:
|
|
||||||
continue
|
|
||||||
location.resolve()
|
location.resolve()
|
||||||
return iter(self.iterable_class(self))
|
return iter(self.iterable_class(self))
|
||||||
|
|
||||||
|
|
||||||
|
class ExecutableFormatSource(Source):
|
||||||
|
"""Source class that uses a simple executable format to start games"""
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def executable_format(self) -> str:
|
||||||
|
"""The executable format used to construct game executables"""
|
||||||
|
|
||||||
|
def make_executable(self, *args, **kwargs) -> str:
|
||||||
|
"""Use the executable format to"""
|
||||||
|
return self.executable_format.format(args, kwargs)
|
||||||
|
|
||||||
|
|
||||||
# pylint: disable=abstract-method
|
# pylint: disable=abstract-method
|
||||||
class URLExecutableSource(Source):
|
class URLExecutableSource(ExecutableFormatSource):
|
||||||
"""Source class that use custom URLs to start games"""
|
"""Source class that use custom URLs to start games"""
|
||||||
|
|
||||||
url_format: str
|
url_format: str
|
||||||
|
|||||||
@@ -120,19 +120,25 @@ 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(
|
|
||||||
schema_key="steam-location",
|
def __init__(self) -> None:
|
||||||
candidates=(
|
super().__init__()
|
||||||
shared.home / ".steam" / "steam",
|
self.locations = SteamLocations(
|
||||||
shared.data_dir / "Steam",
|
Location(
|
||||||
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
|
schema_key="steam-location",
|
||||||
shared.programfiles32_dir / "Steam",
|
candidates=(
|
||||||
),
|
shared.home / ".steam" / "steam",
|
||||||
paths={
|
shared.data_dir / "Steam",
|
||||||
"libraryfolders.vdf": LocationSubPath("steamapps/libraryfolders.vdf"),
|
shared.flatpak_dir / "com.valvesoftware.Steam" / "data" / "Steam",
|
||||||
"librarycache": LocationSubPath("appcache/librarycache", True),
|
shared.programfiles32_dir / "Steam",
|
||||||
},
|
),
|
||||||
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
paths={
|
||||||
|
"libraryfolders.vdf": LocationSubPath(
|
||||||
|
"steamapps/libraryfolders.vdf"
|
||||||
|
),
|
||||||
|
"librarycache": LocationSubPath("appcache/librarycache", True),
|
||||||
|
},
|
||||||
|
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class ColorLogFormatter(Formatter):
|
|||||||
RED = "\033[31m"
|
RED = "\033[31m"
|
||||||
YELLOW = "\033[33m"
|
YELLOW = "\033[33m"
|
||||||
|
|
||||||
def format(self, record: LogRecord):
|
def format(self, record: LogRecord) -> str:
|
||||||
super_format = super().format(record)
|
super_format = super().format(record)
|
||||||
match record.levelname:
|
match record.levelname:
|
||||||
case "CRITICAL":
|
case "CRITICAL":
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
import lzma
|
import lzma
|
||||||
from io import StringIO
|
from io import TextIOWrapper
|
||||||
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
|
||||||
|
|
||||||
@@ -37,7 +38,7 @@ class SessionFileHandler(StreamHandler):
|
|||||||
|
|
||||||
backup_count: int
|
backup_count: int
|
||||||
filename: Path
|
filename: Path
|
||||||
log_file: StringIO = None
|
log_file: Optional[TextIOWrapper] = None
|
||||||
|
|
||||||
def create_dir(self) -> None:
|
def create_dir(self) -> None:
|
||||||
"""Create the log dir if needed"""
|
"""Create the log dir if needed"""
|
||||||
@@ -83,7 +84,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):
|
def rotate_file(self, path: Path) -> None:
|
||||||
"""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
|
||||||
@@ -128,5 +129,6 @@ class SessionFileHandler(StreamHandler):
|
|||||||
super().__init__(self.log_file)
|
super().__init__(self.log_file)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.log_file.close()
|
if self.log_file:
|
||||||
|
self.log_file.close()
|
||||||
super().close()
|
super().close()
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ import sys
|
|||||||
from src import shared
|
from src import shared
|
||||||
|
|
||||||
|
|
||||||
def setup_logging():
|
def setup_logging() -> None:
|
||||||
"""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():
|
|||||||
logging_dot_config.dictConfig(config)
|
logging_dot_config.dictConfig(config)
|
||||||
|
|
||||||
|
|
||||||
def log_system_info():
|
def log_system_info() -> None:
|
||||||
"""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)
|
||||||
|
|||||||
87
src/main.py
87
src/main.py
@@ -21,6 +21,7 @@ import json
|
|||||||
import lzma
|
import lzma
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import gi
|
import gi
|
||||||
|
|
||||||
@@ -40,13 +41,13 @@ 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.local_cover_manager import LocalCoverManager
|
|
||||||
from src.store.managers.online_cover_manager import OnlineCoverManager
|
|
||||||
from src.store.managers.sgdb_manager import SGDBManager
|
from src.store.managers.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
|
||||||
@@ -55,15 +56,15 @@ from src.window import CartridgesWindow
|
|||||||
|
|
||||||
|
|
||||||
class CartridgesApplication(Adw.Application):
|
class CartridgesApplication(Adw.Application):
|
||||||
win = None
|
win: CartridgesWindow
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self) -> None:
|
||||||
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): # pylint: disable=arguments-differ
|
def do_activate(self) -> None: # pylint: disable=arguments-differ
|
||||||
"""Called on app creation"""
|
"""Called on app creation"""
|
||||||
|
|
||||||
setup_logging()
|
setup_logging()
|
||||||
@@ -97,9 +98,8 @@ class CartridgesApplication(Adw.Application):
|
|||||||
self.load_games_from_disk()
|
self.load_games_from_disk()
|
||||||
|
|
||||||
# Add rest of the managers for game imports
|
# Add rest of the managers for game imports
|
||||||
shared.store.add_manager(LocalCoverManager())
|
shared.store.add_manager(CoverManager())
|
||||||
shared.store.add_manager(SteamAPIManager())
|
shared.store.add_manager(SteamAPIManager())
|
||||||
shared.store.add_manager(OnlineCoverManager())
|
|
||||||
shared.store.add_manager(SGDBManager())
|
shared.store.add_manager(SGDBManager())
|
||||||
shared.store.toggle_manager_in_pipelines(FileManager, True)
|
shared.store.toggle_manager_in_pipelines(FileManager, True)
|
||||||
|
|
||||||
@@ -142,14 +142,17 @@ class CartridgesApplication(Adw.Application):
|
|||||||
|
|
||||||
self.win.present()
|
self.win.present()
|
||||||
|
|
||||||
def load_games_from_disk(self):
|
def load_games_from_disk(self) -> None:
|
||||||
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():
|
||||||
data = json.load(game_file.open())
|
try:
|
||||||
|
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):
|
def on_about_action(self, *_args: Any) -> None:
|
||||||
# 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):
|
||||||
@@ -174,9 +177,10 @@ 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",
|
||||||
"Domenico https://github.com/Domefemia",
|
|
||||||
"Paweł Lidwin https://github.com/imLinguin",
|
"Paweł Lidwin https://github.com/imLinguin",
|
||||||
|
"Domenico https://github.com/Domefemia",
|
||||||
"Rafael Mardojai CM https://mardojai.com",
|
"Rafael Mardojai CM https://mardojai.com",
|
||||||
],
|
],
|
||||||
designers=("kramo https://kramo.hu",),
|
designers=("kramo https://kramo.hu",),
|
||||||
@@ -192,8 +196,12 @@ class CartridgesApplication(Adw.Application):
|
|||||||
about.present()
|
about.present()
|
||||||
|
|
||||||
def on_preferences_action(
|
def on_preferences_action(
|
||||||
self, _action=None, _parameter=None, page_name=None, expander_row=None
|
self,
|
||||||
):
|
_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)
|
||||||
@@ -203,73 +211,76 @@ class CartridgesApplication(Adw.Application):
|
|||||||
|
|
||||||
return win
|
return win
|
||||||
|
|
||||||
def on_launch_game_action(self, *_args):
|
def on_launch_game_action(self, *_args: Any) -> None:
|
||||||
self.win.active_game.launch()
|
self.win.active_game.launch()
|
||||||
|
|
||||||
def on_hide_game_action(self, *_args):
|
def on_hide_game_action(self, *_args: Any) -> None:
|
||||||
self.win.active_game.toggle_hidden()
|
self.win.active_game.toggle_hidden()
|
||||||
|
|
||||||
def on_edit_game_action(self, *_args):
|
def on_edit_game_action(self, *_args: Any) -> None:
|
||||||
DetailsWindow(self.win.active_game)
|
DetailsWindow(self.win.active_game)
|
||||||
|
|
||||||
def on_add_game_action(self, *_args):
|
def on_add_game_action(self, *_args: Any) -> None:
|
||||||
DetailsWindow()
|
DetailsWindow()
|
||||||
|
|
||||||
def on_import_action(self, *_args):
|
def on_import_action(self, *_args: Any) -> None:
|
||||||
importer = Importer()
|
shared.importer = Importer()
|
||||||
|
|
||||||
if shared.schema.get_boolean("lutris"):
|
if shared.schema.get_boolean("lutris"):
|
||||||
importer.add_source(LutrisSource())
|
shared.importer.add_source(LutrisSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("steam"):
|
if shared.schema.get_boolean("steam"):
|
||||||
importer.add_source(SteamSource())
|
shared.importer.add_source(SteamSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("heroic"):
|
if shared.schema.get_boolean("heroic"):
|
||||||
importer.add_source(HeroicSource())
|
shared.importer.add_source(HeroicSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("bottles"):
|
if shared.schema.get_boolean("bottles"):
|
||||||
importer.add_source(BottlesSource())
|
shared.importer.add_source(BottlesSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("flatpak"):
|
if shared.schema.get_boolean("flatpak"):
|
||||||
importer.add_source(FlatpakSource())
|
shared.importer.add_source(FlatpakSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("itch"):
|
if shared.schema.get_boolean("itch"):
|
||||||
importer.add_source(ItchSource())
|
shared.importer.add_source(ItchSource())
|
||||||
|
|
||||||
if shared.schema.get_boolean("legendary"):
|
if shared.schema.get_boolean("legendary"):
|
||||||
importer.add_source(LegendarySource())
|
shared.importer.add_source(LegendarySource())
|
||||||
|
|
||||||
importer.run()
|
if shared.schema.get_boolean("retroarch"):
|
||||||
|
shared.importer.add_source(RetroarchSource())
|
||||||
|
|
||||||
def on_remove_game_action(self, *_args):
|
shared.importer.run()
|
||||||
|
|
||||||
|
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):
|
def on_remove_game_details_view_action(self, *_args: Any) -> None:
|
||||||
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):
|
def search(self, uri: str) -> None:
|
||||||
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):
|
def on_igdb_search_action(self, *_args: Any) -> None:
|
||||||
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):
|
def on_sgdb_search_action(self, *_args: Any) -> None:
|
||||||
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):
|
def on_protondb_search_action(self, *_args: Any) -> None:
|
||||||
self.search("https://www.protondb.com/search?q=")
|
self.search("https://www.protondb.com/search?q=")
|
||||||
|
|
||||||
def on_lutris_search_action(self, *_args):
|
def on_lutris_search_action(self, *_args: Any) -> None:
|
||||||
self.search("https://lutris.net/games?q=")
|
self.search("https://lutris.net/games?q=")
|
||||||
|
|
||||||
def on_hltb_search_action(self, *_args):
|
def on_hltb_search_action(self, *_args: Any) -> None:
|
||||||
self.search("https://howlongtobeat.com/?q=")
|
self.search("https://howlongtobeat.com/?q=")
|
||||||
|
|
||||||
def on_quit_action(self, *_args):
|
def on_quit_action(self, *_args: Any) -> None:
|
||||||
self.quit()
|
self.quit()
|
||||||
|
|
||||||
def create_actions(self, actions):
|
def create_actions(self, actions: set) -> None:
|
||||||
for action in actions:
|
for action in actions:
|
||||||
simple_action = Gio.SimpleAction.new(action[0], None)
|
simple_action = Gio.SimpleAction.new(action[0], None)
|
||||||
|
|
||||||
@@ -285,7 +296,7 @@ class CartridgesApplication(Adw.Application):
|
|||||||
scope.add_action(simple_action)
|
scope.add_action(simple_action)
|
||||||
|
|
||||||
|
|
||||||
def main(_version):
|
def main(_version: int) -> Any:
|
||||||
"""App entry point"""
|
"""App entry point"""
|
||||||
app = CartridgesApplication()
|
app = CartridgesApplication()
|
||||||
return app.run(sys.argv)
|
return app.run(sys.argv)
|
||||||
|
|||||||
@@ -21,10 +21,12 @@ 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.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
|
||||||
@@ -32,6 +34,7 @@ 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
|
||||||
@@ -51,6 +54,8 @@ 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()
|
||||||
@@ -83,6 +88,10 @@ 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()
|
||||||
@@ -100,10 +109,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()
|
removed_games: set[Game] = set()
|
||||||
warning_menu_buttons = {}
|
warning_menu_buttons: dict = {}
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
self.win = shared.win
|
self.win = shared.win
|
||||||
self.file_chooser = Gtk.FileDialog()
|
self.file_chooser = Gtk.FileDialog()
|
||||||
@@ -139,6 +148,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
ItchSource,
|
ItchSource,
|
||||||
LegendarySource,
|
LegendarySource,
|
||||||
LutrisSource,
|
LutrisSource,
|
||||||
|
RetroarchSource,
|
||||||
SteamSource,
|
SteamSource,
|
||||||
):
|
):
|
||||||
source = source_class()
|
source = source_class()
|
||||||
@@ -149,7 +159,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
self.init_source_row(source)
|
self.init_source_row(source)
|
||||||
|
|
||||||
# SteamGridDB
|
# SteamGridDB
|
||||||
def sgdb_key_changed(*_args):
|
def sgdb_key_changed(*_args: Any) -> None:
|
||||||
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"))
|
||||||
@@ -163,7 +173,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
def set_sgdb_sensitive(widget):
|
def set_sgdb_sensitive(widget: Adw.EntryRow) -> None:
|
||||||
if not widget.get_text():
|
if not widget.get_text():
|
||||||
shared.schema.set_boolean("sgdb", False)
|
shared.schema.set_boolean("sgdb", False)
|
||||||
|
|
||||||
@@ -174,10 +184,11 @@ 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",
|
||||||
@@ -188,13 +199,13 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
"sgdb",
|
"sgdb",
|
||||||
"sgdb-prefer",
|
"sgdb-prefer",
|
||||||
"sgdb-animated",
|
"sgdb-animated",
|
||||||
)
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
def get_switch(self, setting):
|
def get_switch(self, setting: str) -> Any:
|
||||||
return getattr(self, f'{setting.replace("-", "_")}_switch')
|
return getattr(self, f'{setting.replace("-", "_")}_switch')
|
||||||
|
|
||||||
def bind_switches(self, settings):
|
def bind_switches(self, settings: set[str]) -> None:
|
||||||
for setting in settings:
|
for setting in settings:
|
||||||
shared.schema.bind(
|
shared.schema.bind(
|
||||||
setting,
|
setting,
|
||||||
@@ -203,10 +214,12 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
Gio.SettingsBindFlags.DEFAULT,
|
Gio.SettingsBindFlags.DEFAULT,
|
||||||
)
|
)
|
||||||
|
|
||||||
def choose_folder(self, _widget, callback, callback_data=None):
|
def choose_folder(
|
||||||
|
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):
|
def undo_remove_all(self, *_args: Any) -> None:
|
||||||
for game in self.removed_games:
|
for game in self.removed_games:
|
||||||
game.removed = False
|
game.removed = False
|
||||||
game.save()
|
game.save()
|
||||||
@@ -215,7 +228,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):
|
def remove_all_games(self, *_args: Any) -> None:
|
||||||
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)
|
||||||
@@ -228,7 +241,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
|
|
||||||
self.add_toast(self.toast)
|
self.add_toast(self.toast)
|
||||||
|
|
||||||
def reset_app(self, *_args):
|
def reset_app(self, *_args: Any) -> None:
|
||||||
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)
|
||||||
@@ -246,28 +259,24 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
|
|
||||||
shared.win.get_application().quit()
|
shared.win.get_application().quit()
|
||||||
|
|
||||||
def update_source_action_row_paths(self, source):
|
def update_source_action_row_paths(self, source: Source) -> None:
|
||||||
"""Set the dir subtitle for a source's action rows"""
|
"""Set the dir subtitle for a source's action rows"""
|
||||||
for location in ("data", "config", "cache"):
|
for location_name, location in source.locations._asdict().items():
|
||||||
# Get the action row to subtitle
|
# Get the action row to subtitle
|
||||||
action_row = getattr(
|
action_row = getattr(
|
||||||
self, f"{source.source_id}_{location}_action_row", None
|
self, f"{source.source_id}_{location_name}_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):
|
def resolve_locations(self, source: Source) -> None:
|
||||||
"""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, label):
|
def clear_warning_selection(_widget: Any, label: Gtk.Label) -> None:
|
||||||
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():
|
||||||
@@ -317,40 +326,30 @@ 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):
|
def init_source_row(self, source: Source) -> None:
|
||||||
"""Initialize a preference row for a source class"""
|
"""Initialize a preference row for a source class"""
|
||||||
|
|
||||||
def set_dir(_widget, result, location_name):
|
def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None:
|
||||||
"""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 = getattr(source.locations, location_name)
|
location = source.locations._asdict()[location_name]
|
||||||
if location.check_candidate(path):
|
if location.check_candidate(path):
|
||||||
# Set the schema
|
shared.schema.set_string(location.schema_key, str(path))
|
||||||
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(self.warning_menu_buttons[source.source_id])
|
action_row.remove( # type: ignore
|
||||||
|
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:
|
||||||
@@ -363,7 +362,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
|||||||
_("Set Location"),
|
_("Set Location"),
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_response(widget, response):
|
def on_response(widget: Any, response: str) -> None:
|
||||||
if response == "choose_folder":
|
if response == "choose_folder":
|
||||||
self.choose_folder(widget, set_dir, location_name)
|
self.choose_folder(widget, set_dir, location_name)
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ 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(
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class AsyncManager(Manager):
|
|||||||
def _task_thread_func(self, _task, _source_object, data, _cancellable):
|
def _task_thread_func(self, _task, _source_object, data, _cancellable):
|
||||||
"""Task thread entry point"""
|
"""Task thread entry point"""
|
||||||
game, additional_data, *_rest = data
|
game, additional_data, *_rest = data
|
||||||
self.execute_resilient_manager_logic(game, additional_data)
|
self.run(game, additional_data)
|
||||||
|
|
||||||
def _task_callback(self, _source_object, _result, data):
|
def _task_callback(self, _source_object, _result, data):
|
||||||
"""Method run after the task is done"""
|
"""Method run after the task is done"""
|
||||||
|
|||||||
197
src/store/managers/cover_manager.py
Normal file
197
src/store/managers/cover_manager.py
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
# local_cover_manager.py
|
||||||
|
#
|
||||||
|
# Copyright 2023 Geoffrey Coulaud
|
||||||
|
# Copyright 2023 kramo
|
||||||
|
#
|
||||||
|
# This program is free software: you can redistribute it and/or modify
|
||||||
|
# it under the terms of the GNU General Public License as published by
|
||||||
|
# the Free Software Foundation, either version 3 of the License, or
|
||||||
|
# (at your option) any later version.
|
||||||
|
#
|
||||||
|
# This program is distributed in the hope that it will be useful,
|
||||||
|
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
# GNU General Public License for more details.
|
||||||
|
#
|
||||||
|
# You should have received a copy of the GNU General Public License
|
||||||
|
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
#
|
||||||
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import NamedTuple
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from gi.repository import Gio, GdkPixbuf
|
||||||
|
from requests.exceptions import HTTPError, SSLError
|
||||||
|
|
||||||
|
from src import shared
|
||||||
|
from src.game import Game
|
||||||
|
from src.store.managers.manager import Manager
|
||||||
|
from src.store.managers.steam_api_manager import SteamAPIManager
|
||||||
|
from src.utils.save_cover import resize_cover, save_cover
|
||||||
|
|
||||||
|
|
||||||
|
class ImageSize(NamedTuple):
|
||||||
|
width: float = 0
|
||||||
|
height: float = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aspect_ratio(self) -> float:
|
||||||
|
return self.width / self.height
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.width}x{self.height}"
|
||||||
|
|
||||||
|
def __mul__(self, scale: float | int) -> "ImageSize":
|
||||||
|
return ImageSize(
|
||||||
|
self.width * scale,
|
||||||
|
self.height * scale,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __truediv__(self, divisor: float | int) -> "ImageSize":
|
||||||
|
return self * (1 / divisor)
|
||||||
|
|
||||||
|
def __add__(self, other_size: "ImageSize") -> "ImageSize":
|
||||||
|
return ImageSize(
|
||||||
|
self.width + other_size.width,
|
||||||
|
self.height + other_size.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __sub__(self, other_size: "ImageSize") -> "ImageSize":
|
||||||
|
return self + (other_size * -1)
|
||||||
|
|
||||||
|
def element_wise_div(self, other_size: "ImageSize") -> "ImageSize":
|
||||||
|
"""Divide every element of self by the equivalent in the other size"""
|
||||||
|
return ImageSize(
|
||||||
|
self.width / other_size.width,
|
||||||
|
self.height / other_size.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def element_wise_mul(self, other_size: "ImageSize") -> "ImageSize":
|
||||||
|
"""Multiply every element of self by the equivalent in the other size"""
|
||||||
|
return ImageSize(
|
||||||
|
self.width * other_size.width,
|
||||||
|
self.height * other_size.height,
|
||||||
|
)
|
||||||
|
|
||||||
|
def invert(self) -> "ImageSize":
|
||||||
|
"""Invert the element of self"""
|
||||||
|
return ImageSize(1, 1).element_wise_div(self)
|
||||||
|
|
||||||
|
|
||||||
|
class CoverManager(Manager):
|
||||||
|
"""
|
||||||
|
Manager in charge of adding the cover image of the game
|
||||||
|
|
||||||
|
Order of priority is:
|
||||||
|
1. local cover
|
||||||
|
2. icon cover
|
||||||
|
3. online cover
|
||||||
|
"""
|
||||||
|
|
||||||
|
run_after = (SteamAPIManager,)
|
||||||
|
retryable_on = (HTTPError, SSLError, ConnectionError)
|
||||||
|
|
||||||
|
def download_image(self, url: str) -> Path:
|
||||||
|
image_file = Gio.File.new_tmp()[0]
|
||||||
|
path = Path(image_file.get_path())
|
||||||
|
with requests.get(url, timeout=5) as cover:
|
||||||
|
cover.raise_for_status()
|
||||||
|
path.write_bytes(cover.content)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def is_stretchable(self, source_size: ImageSize, cover_size: ImageSize) -> bool:
|
||||||
|
is_taller = source_size.aspect_ratio < cover_size.aspect_ratio
|
||||||
|
if is_taller:
|
||||||
|
return True
|
||||||
|
max_stretch = 0.12
|
||||||
|
resized_height = (1 / source_size.aspect_ratio) * cover_size.width
|
||||||
|
stretch = 1 - (resized_height / cover_size.height)
|
||||||
|
return stretch <= max_stretch
|
||||||
|
|
||||||
|
def save_composited_cover(
|
||||||
|
self,
|
||||||
|
game: Game,
|
||||||
|
image_path: Path,
|
||||||
|
scale: float = 1,
|
||||||
|
blur_size: ImageSize = ImageSize(2, 2),
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
Save the image composited with a background blur.
|
||||||
|
If the image is stretchable, just stretch it.
|
||||||
|
|
||||||
|
:param game: The game to save the cover for
|
||||||
|
:param path: Path where the source image is located
|
||||||
|
:param scale:
|
||||||
|
Scale of the smalled image side
|
||||||
|
compared to the corresponding side in the cover
|
||||||
|
:param blur_size: Size of the downscaled image used for the blur
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Load source image
|
||||||
|
source = GdkPixbuf.Pixbuf.new_from_file(str(image_path))
|
||||||
|
source_size = ImageSize(source.get_width(), source.get_height())
|
||||||
|
cover_size = ImageSize._make(shared.image_size)
|
||||||
|
|
||||||
|
# Stretch if possible
|
||||||
|
if scale == 1 and self.is_stretchable(source_size, cover_size):
|
||||||
|
save_cover(game.game_id, resize_cover(pixbuf=source))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Create the blurred cover background
|
||||||
|
# fmt: off
|
||||||
|
cover = (
|
||||||
|
source
|
||||||
|
.scale_simple(*blur_size, GdkPixbuf.InterpType.BILINEAR)
|
||||||
|
.scale_simple(*cover_size, GdkPixbuf.InterpType.BILINEAR)
|
||||||
|
)
|
||||||
|
# fmt: on
|
||||||
|
|
||||||
|
# Scale to fit, apply scaling, then center
|
||||||
|
uniform_scale = scale * min(cover_size.element_wise_div(source_size))
|
||||||
|
source_in_cover_size = source_size * uniform_scale
|
||||||
|
source_in_cover_position = (cover_size - source_in_cover_size) / 2
|
||||||
|
|
||||||
|
# Center the scaled source image in the cover
|
||||||
|
source.composite(
|
||||||
|
cover,
|
||||||
|
*source_in_cover_position,
|
||||||
|
*source_in_cover_size,
|
||||||
|
*source_in_cover_position,
|
||||||
|
uniform_scale,
|
||||||
|
uniform_scale,
|
||||||
|
GdkPixbuf.InterpType.BILINEAR,
|
||||||
|
255,
|
||||||
|
)
|
||||||
|
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
||||||
|
|
||||||
|
def main(self, game: Game, additional_data: dict) -> None:
|
||||||
|
if game.blacklisted:
|
||||||
|
return
|
||||||
|
for key in (
|
||||||
|
"local_image_path",
|
||||||
|
"local_icon_path",
|
||||||
|
"online_cover_url",
|
||||||
|
):
|
||||||
|
# Get an image path
|
||||||
|
if not (value := additional_data.get(key)):
|
||||||
|
continue
|
||||||
|
if key == "online_cover_url":
|
||||||
|
image_path = self.download_image(value)
|
||||||
|
else:
|
||||||
|
image_path = Path(value)
|
||||||
|
if not image_path.is_file():
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Icon cover
|
||||||
|
if key == "local_icon_path":
|
||||||
|
self.save_composited_cover(
|
||||||
|
game,
|
||||||
|
image_path,
|
||||||
|
scale=0.7,
|
||||||
|
blur_size=ImageSize(1, 2),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
self.save_composited_cover(game, image_path)
|
||||||
@@ -30,7 +30,7 @@ class DisplayManager(Manager):
|
|||||||
run_after = (SteamAPIManager, SGDBManager)
|
run_after = (SteamAPIManager, SGDBManager)
|
||||||
signals = {"update-ready"}
|
signals = {"update-ready"}
|
||||||
|
|
||||||
def manager_logic(self, game: Game, _additional_data: dict) -> None:
|
def main(self, game: Game, _additional_data: dict) -> None:
|
||||||
if game.get_parent():
|
if game.get_parent():
|
||||||
game.get_parent().get_parent().remove(game)
|
game.get_parent().get_parent().remove(game)
|
||||||
if game.get_parent():
|
if game.get_parent():
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ class FileManager(AsyncManager):
|
|||||||
run_after = (SteamAPIManager,)
|
run_after = (SteamAPIManager,)
|
||||||
signals = {"save-ready"}
|
signals = {"save-ready"}
|
||||||
|
|
||||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
def main(self, game: Game, additional_data: dict) -> None:
|
||||||
if additional_data.get("skip_save"): # Skip saving when loading games from disk
|
if additional_data.get("skip_save"): # Skip saving when loading games from disk
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
# local_cover_manager.py
|
|
||||||
#
|
|
||||||
# Copyright 2023 Geoffrey Coulaud
|
|
||||||
# Copyright 2023 kramo
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
import logging
|
|
||||||
|
|
||||||
from gi.repository import GdkPixbuf
|
|
||||||
|
|
||||||
from src import shared
|
|
||||||
from src.game import Game
|
|
||||||
from src.store.managers.manager import Manager
|
|
||||||
from src.store.managers.steam_api_manager import SteamAPIManager
|
|
||||||
from src.utils.save_cover import resize_cover, save_cover
|
|
||||||
|
|
||||||
|
|
||||||
class LocalCoverManager(Manager):
|
|
||||||
"""Manager in charge of adding the local cover image of the game"""
|
|
||||||
|
|
||||||
run_after = (SteamAPIManager,)
|
|
||||||
|
|
||||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
|
||||||
if image_path := additional_data.get("local_image_path"):
|
|
||||||
if not image_path.is_file():
|
|
||||||
logging.error("Local image path is not a file: %s", image_path)
|
|
||||||
return
|
|
||||||
save_cover(game.game_id, resize_cover(image_path))
|
|
||||||
elif icon_path := additional_data.get("local_icon_path"):
|
|
||||||
cover_width, cover_height = shared.image_size
|
|
||||||
|
|
||||||
dest_width = cover_width * 0.7
|
|
||||||
dest_height = cover_width * 0.7
|
|
||||||
|
|
||||||
dest_x = cover_width * 0.15
|
|
||||||
dest_y = (cover_height - dest_height) / 2
|
|
||||||
|
|
||||||
image = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)).scale_simple(
|
|
||||||
dest_width, dest_height, GdkPixbuf.InterpType.BILINEAR
|
|
||||||
)
|
|
||||||
|
|
||||||
cover = image.scale_simple(
|
|
||||||
1, 2, GdkPixbuf.InterpType.BILINEAR
|
|
||||||
).scale_simple(cover_width, cover_height, GdkPixbuf.InterpType.BILINEAR)
|
|
||||||
|
|
||||||
image.composite(
|
|
||||||
cover,
|
|
||||||
dest_x,
|
|
||||||
dest_y,
|
|
||||||
dest_width,
|
|
||||||
dest_height,
|
|
||||||
dest_x,
|
|
||||||
dest_y,
|
|
||||||
1,
|
|
||||||
1,
|
|
||||||
GdkPixbuf.InterpType.BILINEAR,
|
|
||||||
255,
|
|
||||||
)
|
|
||||||
|
|
||||||
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
|
||||||
@@ -46,11 +46,11 @@ class Manager(ErrorProducer):
|
|||||||
max_tries: int = 3
|
max_tries: int = 3
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self) -> str:
|
||||||
return type(self).__name__
|
return type(self).__name__
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
def main(self, game: Game, additional_data: dict) -> None:
|
||||||
"""
|
"""
|
||||||
Manager specific logic triggered by the run method
|
Manager specific logic triggered by the run method
|
||||||
* Implemented by final child classes
|
* Implemented by final child classes
|
||||||
@@ -59,13 +59,13 @@ class Manager(ErrorProducer):
|
|||||||
* May raise other exceptions that will be reported
|
* May raise other exceptions that will be reported
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def execute_resilient_manager_logic(self, game: Game, additional_data: dict):
|
def run(self, game: Game, additional_data: dict) -> None:
|
||||||
"""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):
|
def handle_error(error: Exception) -> None:
|
||||||
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 error in self.continue_on:
|
if type(error) in self.continue_on:
|
||||||
# Handle skippable errors (skip silently)
|
# Handle skippable errors (skip silently)
|
||||||
return
|
return
|
||||||
|
|
||||||
if error in self.retryable_on:
|
if type(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,9 +104,9 @@ 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():
|
def try_manager_logic() -> None:
|
||||||
try:
|
try:
|
||||||
self.manager_logic(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
|
||||||
handle_error(error)
|
handle_error(error)
|
||||||
|
|
||||||
@@ -116,5 +116,5 @@ class Manager(ErrorProducer):
|
|||||||
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
|
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Pass the game through the manager"""
|
"""Pass the game through the manager"""
|
||||||
self.execute_resilient_manager_logic(game, additional_data)
|
self.run(game, additional_data)
|
||||||
callback(self)
|
callback(self)
|
||||||
|
|||||||
@@ -1,126 +0,0 @@
|
|||||||
# online_cover_manager.py
|
|
||||||
#
|
|
||||||
# Copyright 2023 Geoffrey Coulaud
|
|
||||||
#
|
|
||||||
# This program is free software: you can redistribute it and/or modify
|
|
||||||
# it under the terms of the GNU General Public License as published by
|
|
||||||
# the Free Software Foundation, either version 3 of the License, or
|
|
||||||
# (at your option) any later version.
|
|
||||||
#
|
|
||||||
# This program is distributed in the hope that it will be useful,
|
|
||||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
# GNU General Public License for more details.
|
|
||||||
#
|
|
||||||
# You should have received a copy of the GNU General Public License
|
|
||||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
|
||||||
#
|
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
|
||||||
|
|
||||||
import logging
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
import requests
|
|
||||||
from gi.repository import Gio, GdkPixbuf
|
|
||||||
from requests.exceptions import HTTPError, SSLError
|
|
||||||
from PIL import Image
|
|
||||||
|
|
||||||
from src import shared
|
|
||||||
from src.game import Game
|
|
||||||
from src.store.managers.local_cover_manager import LocalCoverManager
|
|
||||||
from src.store.managers.manager import Manager
|
|
||||||
from src.utils.save_cover import resize_cover, save_cover
|
|
||||||
|
|
||||||
|
|
||||||
class OnlineCoverManager(Manager):
|
|
||||||
"""Manager that downloads game covers from URLs"""
|
|
||||||
|
|
||||||
run_after = (LocalCoverManager,)
|
|
||||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
|
||||||
|
|
||||||
def save_composited_cover(
|
|
||||||
self,
|
|
||||||
game: Game,
|
|
||||||
image_file: Gio.File,
|
|
||||||
original_width: int,
|
|
||||||
original_height: int,
|
|
||||||
target_width: int,
|
|
||||||
target_height: int,
|
|
||||||
) -> None:
|
|
||||||
"""Save the image composited with a background blur to fit the cover size"""
|
|
||||||
|
|
||||||
logging.debug(
|
|
||||||
"Compositing image for %s (%s) %dx%d -> %dx%d",
|
|
||||||
game.name,
|
|
||||||
game.game_id,
|
|
||||||
original_width,
|
|
||||||
original_height,
|
|
||||||
target_width,
|
|
||||||
target_height,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Load game image
|
|
||||||
image = GdkPixbuf.Pixbuf.new_from_stream(image_file.read())
|
|
||||||
|
|
||||||
# Create background blur of the size of the cover
|
|
||||||
cover = image.scale_simple(2, 2, GdkPixbuf.InterpType.BILINEAR).scale_simple(
|
|
||||||
target_width, target_height, GdkPixbuf.InterpType.BILINEAR
|
|
||||||
)
|
|
||||||
|
|
||||||
# Center the image above the blurred background
|
|
||||||
scale = min(target_width / original_width, target_height / original_height)
|
|
||||||
left_padding = (target_width - original_width * scale) / 2
|
|
||||||
top_padding = (target_height - original_height * scale) / 2
|
|
||||||
image.composite(
|
|
||||||
cover,
|
|
||||||
# Top left of overwritten area on the destination
|
|
||||||
left_padding,
|
|
||||||
top_padding,
|
|
||||||
# Size of the overwritten area on the destination
|
|
||||||
original_width * scale,
|
|
||||||
original_height * scale,
|
|
||||||
# Offset
|
|
||||||
left_padding,
|
|
||||||
top_padding,
|
|
||||||
# Scale to apply to the resized image
|
|
||||||
scale,
|
|
||||||
scale,
|
|
||||||
# Compositing stuff
|
|
||||||
GdkPixbuf.InterpType.BILINEAR,
|
|
||||||
255,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Resize and save the cover
|
|
||||||
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
|
||||||
|
|
||||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
|
||||||
# Ensure that we have a cover to download
|
|
||||||
cover_url = additional_data.get("online_cover_url")
|
|
||||||
if not cover_url:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Download cover
|
|
||||||
image_file = Gio.File.new_tmp()[0]
|
|
||||||
image_path = Path(image_file.get_path())
|
|
||||||
with requests.get(cover_url, timeout=5) as cover:
|
|
||||||
cover.raise_for_status()
|
|
||||||
image_path.write_bytes(cover.content)
|
|
||||||
|
|
||||||
# Get image size
|
|
||||||
cover_width, cover_height = shared.image_size
|
|
||||||
with Image.open(image_path) as pil_image:
|
|
||||||
width, height = pil_image.size
|
|
||||||
|
|
||||||
# Composite if the image is shorter and the stretch amount is too high
|
|
||||||
aspect_ratio = width / height
|
|
||||||
target_aspect_ratio = cover_width / cover_height
|
|
||||||
is_taller = aspect_ratio < target_aspect_ratio
|
|
||||||
resized_height = height / width * cover_width
|
|
||||||
stretch = 1 - (resized_height / cover_height)
|
|
||||||
max_stretch = 0.12
|
|
||||||
if is_taller or stretch <= max_stretch:
|
|
||||||
save_cover(game.game_id, resize_cover(image_path))
|
|
||||||
else:
|
|
||||||
self.save_composited_cover(
|
|
||||||
game, image_file, width, height, cover_width, cover_height
|
|
||||||
)
|
|
||||||
@@ -24,19 +24,18 @@ from requests.exceptions import HTTPError, SSLError
|
|||||||
from src.errors.friendly_error import FriendlyError
|
from src.errors.friendly_error import FriendlyError
|
||||||
from src.game import Game
|
from src.game import Game
|
||||||
from src.store.managers.async_manager import AsyncManager
|
from src.store.managers.async_manager import AsyncManager
|
||||||
from src.store.managers.local_cover_manager import LocalCoverManager
|
|
||||||
from src.store.managers.online_cover_manager import OnlineCoverManager
|
|
||||||
from src.store.managers.steam_api_manager import SteamAPIManager
|
from src.store.managers.steam_api_manager import SteamAPIManager
|
||||||
|
from src.store.managers.cover_manager import CoverManager
|
||||||
from src.utils.steamgriddb import SGDBAuthError, SGDBHelper
|
from src.utils.steamgriddb import SGDBAuthError, SGDBHelper
|
||||||
|
|
||||||
|
|
||||||
class SGDBManager(AsyncManager):
|
class SGDBManager(AsyncManager):
|
||||||
"""Manager in charge of downloading a game's cover from steamgriddb"""
|
"""Manager in charge of downloading a game's cover from steamgriddb"""
|
||||||
|
|
||||||
run_after = (SteamAPIManager, LocalCoverManager, OnlineCoverManager)
|
run_after = (SteamAPIManager, CoverManager)
|
||||||
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
|
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
|
||||||
|
|
||||||
def manager_logic(self, game: Game, _additional_data: dict) -> None:
|
def main(self, game: Game, _additional_data: dict) -> None:
|
||||||
try:
|
try:
|
||||||
sgdb = SGDBHelper()
|
sgdb = SGDBHelper()
|
||||||
sgdb.conditionaly_update_cover(game)
|
sgdb.conditionaly_update_cover(game)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from requests.exceptions import HTTPError, SSLError
|
from requests.exceptions import HTTPError, SSLError
|
||||||
|
from urllib3.exceptions import ConnectionError as Urllib3ConnectionError
|
||||||
|
|
||||||
from src.game import Game
|
from src.game import Game
|
||||||
from src.store.managers.async_manager import AsyncManager
|
from src.store.managers.async_manager import AsyncManager
|
||||||
@@ -32,7 +33,7 @@ from src.utils.steam import (
|
|||||||
class SteamAPIManager(AsyncManager):
|
class SteamAPIManager(AsyncManager):
|
||||||
"""Manager in charge of completing a game's data from the Steam API"""
|
"""Manager in charge of completing a game's data from the Steam API"""
|
||||||
|
|
||||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
retryable_on = (HTTPError, SSLError, Urllib3ConnectionError)
|
||||||
|
|
||||||
steam_api_helper: SteamAPIHelper = None
|
steam_api_helper: SteamAPIHelper = None
|
||||||
steam_rate_limiter: SteamRateLimiter = None
|
steam_rate_limiter: SteamRateLimiter = None
|
||||||
@@ -42,7 +43,7 @@ class SteamAPIManager(AsyncManager):
|
|||||||
self.steam_rate_limiter = SteamRateLimiter()
|
self.steam_rate_limiter = SteamRateLimiter()
|
||||||
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
|
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
|
||||||
|
|
||||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
def main(self, game: Game, additional_data: dict) -> None:
|
||||||
# Skip non-steam games
|
# Skip non-steam games
|
||||||
appid = additional_data.get("steam_appid", None)
|
appid = additional_data.get("steam_appid", None)
|
||||||
if appid is None:
|
if appid is None:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class Pipeline(GObject.Object):
|
|||||||
progress = 1
|
progress = 1
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
def advance(self):
|
def advance(self) -> None:
|
||||||
"""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) -> None:
|
def advanced(self): # type: ignore
|
||||||
"""Signal emitted when the pipeline has advanced"""
|
"""Signal emitted when the pipeline has advanced"""
|
||||||
|
|||||||
@@ -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 MutableMapping, Generator, Any
|
from typing import Any, Generator, MutableMapping, Optional
|
||||||
|
|
||||||
from src import shared
|
from src import shared
|
||||||
from src.game import Game
|
from src.game import Game
|
||||||
@@ -33,12 +33,16 @@ 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"""
|
||||||
@@ -73,13 +77,15 @@ class Store:
|
|||||||
except KeyError:
|
except KeyError:
|
||||||
return default
|
return default
|
||||||
|
|
||||||
def add_manager(self, manager: Manager, in_pipeline=True):
|
def add_manager(self, manager: Manager, in_pipeline: bool = True) -> None:
|
||||||
"""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(self, manager_type: type[Manager], enable: bool):
|
def toggle_manager_in_pipelines(
|
||||||
|
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])
|
||||||
@@ -87,7 +93,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"""
|
"""Remove a game's files, dismiss any loose toasts"""
|
||||||
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",
|
||||||
@@ -95,9 +101,17 @@ 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=True
|
self, game: Game, additional_data: dict, run_pipeline: bool = True
|
||||||
) -> Pipeline | None:
|
) -> Optional[Pipeline]:
|
||||||
"""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
|
||||||
@@ -114,6 +128,7 @@ 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(
|
||||||
@@ -122,15 +137,17 @@ 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
|
||||||
for manager in self.managers.values():
|
for manager in self.managers.values():
|
||||||
for signal in manager.signals:
|
for signal in manager.signals:
|
||||||
game.connect(signal, manager.execute_resilient_manager_logic)
|
game.connect(signal, manager.run)
|
||||||
|
|
||||||
# Add the game to the store
|
# Add the game to the store
|
||||||
if not game.source in self.source_games:
|
if not game.source in self.source_games:
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
# 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
|
|
||||||
@@ -17,10 +17,18 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Adw
|
from typing import Optional
|
||||||
|
|
||||||
|
from gi.repository import Adw, Gtk
|
||||||
|
|
||||||
|
|
||||||
def create_dialog(win, heading, body, extra_option=None, extra_label=None):
|
def create_dialog(
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|||||||
@@ -23,14 +23,14 @@ from pathlib import Path
|
|||||||
|
|
||||||
from src import shared
|
from src import shared
|
||||||
|
|
||||||
old_data_dir = Path.home() / ".local" / "share"
|
old_data_dir = shared.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):
|
def migrate_game_covers(game_path: Path) -> None:
|
||||||
"""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):
|
|||||||
cover_path.rename(destination_cover_path)
|
cover_path.rename(destination_cover_path)
|
||||||
|
|
||||||
|
|
||||||
def migrate_files_v1_to_v2():
|
def migrate_files_v1_to_v2() -> None:
|
||||||
"""
|
"""
|
||||||
Migrate user data from the v1.X locations to the latest location.
|
Migrate user data from the v1.X locations to the latest location.
|
||||||
|
|
||||||
|
|||||||
@@ -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[int] = None
|
timestamps: list[float]
|
||||||
timestamps_lock: Lock = None
|
timestamps_lock: Lock
|
||||||
|
|
||||||
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):
|
def remove_old_entries(self) -> None:
|
||||||
"""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: Optional[int]):
|
def add(self, *new_timestamps: float) -> None:
|
||||||
"""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) -> int:
|
def start(self) -> float:
|
||||||
"""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) -> str:
|
def copy_timestamps(self) -> list[float]:
|
||||||
"""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,51 +79,55 @@ 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 = None
|
pick_history: PickHistory
|
||||||
bucket: BoundedSemaphore = None
|
bucket: BoundedSemaphore
|
||||||
queue: deque[Lock] = None
|
queue: deque[Lock]
|
||||||
queue_lock: Lock = None
|
queue_lock: Lock
|
||||||
|
|
||||||
# Protect the number of tokens behind a lock
|
# Protect the number of tokens behind a lock
|
||||||
__n_tokens_lock: Lock = None
|
__n_tokens_lock: Lock
|
||||||
__n_tokens = 0
|
__n_tokens = 0
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def n_tokens(self):
|
def n_tokens(self) -> int:
|
||||||
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):
|
def n_tokens(self, value: int) -> None:
|
||||||
with self.__n_tokens_lock:
|
with self.__n_tokens_lock:
|
||||||
self.__n_tokens = value
|
self.__n_tokens = value
|
||||||
|
|
||||||
def __init__(
|
def _init_pick_history(self) -> None:
|
||||||
self,
|
"""
|
||||||
refill_period_seconds: Optional[int] = None,
|
Initialize the tocken pick history
|
||||||
refill_period_tokens: Optional[int] = None,
|
(only for use in this class and its children)
|
||||||
burst_tokens: Optional[int] = None,
|
|
||||||
) -> None:
|
By default, creates an empty pick history.
|
||||||
|
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"""
|
||||||
|
|
||||||
# Initialize default values
|
self._init_pick_history()
|
||||||
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()
|
||||||
@@ -147,8 +151,8 @@ class RateLimiter(AbstractContextManager):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
# Compute ideal spacing
|
# Compute ideal spacing
|
||||||
tokens_left = self.refill_period_tokens - len(self.pick_history)
|
tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore
|
||||||
seconds_left = self.pick_history.start + self.refill_period_seconds - time()
|
seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore
|
||||||
try:
|
try:
|
||||||
spacing_seconds = seconds_left / tokens_left
|
spacing_seconds = seconds_left / tokens_left
|
||||||
except ZeroDivisionError:
|
except ZeroDivisionError:
|
||||||
@@ -159,7 +163,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):
|
def refill(self) -> None:
|
||||||
"""Add a token back in the bucket"""
|
"""Add a token back in the bucket"""
|
||||||
sleep(self.refill_spacing)
|
sleep(self.refill_spacing)
|
||||||
try:
|
try:
|
||||||
@@ -170,7 +174,7 @@ class RateLimiter(AbstractContextManager):
|
|||||||
else:
|
else:
|
||||||
self.n_tokens += 1
|
self.n_tokens += 1
|
||||||
|
|
||||||
def refill_thread_func(self):
|
def refill_thread_func(self) -> None:
|
||||||
"""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()
|
||||||
@@ -200,18 +204,18 @@ class RateLimiter(AbstractContextManager):
|
|||||||
self.queue.appendleft(lock)
|
self.queue.appendleft(lock)
|
||||||
return lock
|
return lock
|
||||||
|
|
||||||
def acquire(self):
|
def acquire(self) -> None:
|
||||||
"""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()
|
self.pick_history.add() # type: ignore
|
||||||
|
|
||||||
# --- Support for use in with statements
|
# --- Support for use in with statements
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self) -> None:
|
||||||
self.acquire()
|
self.acquire()
|
||||||
|
|
||||||
def __exit__(self, *_args):
|
def __exit__(self, *_args: Any) -> None:
|
||||||
pass
|
pass
|
||||||
|
|||||||
@@ -18,11 +18,12 @@
|
|||||||
# 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): # pylint: disable=too-many-return-statements
|
def relative_date(timestamp: int) -> Any: # 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:
|
||||||
|
|||||||
@@ -20,14 +20,17 @@
|
|||||||
|
|
||||||
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, Gio, GLib
|
from gi.repository import Gdk, GdkPixbuf, 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(cover_path=None, pixbuf=None):
|
def resize_cover(
|
||||||
|
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
|
||||||
|
|
||||||
@@ -74,7 +77,7 @@ def resize_cover(cover_path=None, pixbuf=None):
|
|||||||
return tmp_path
|
return tmp_path
|
||||||
|
|
||||||
|
|
||||||
def save_cover(game_id, cover_path):
|
def save_cover(game_id: str, cover_path: Path) -> None:
|
||||||
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"
|
||||||
|
|||||||
@@ -21,13 +21,14 @@
|
|||||||
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 PickHistory, RateLimiter
|
from src.utils.rate_limiter import RateLimiter
|
||||||
|
|
||||||
|
|
||||||
class SteamError(Exception):
|
class SteamError(Exception):
|
||||||
@@ -71,16 +72,18 @@ class SteamRateLimiter(RateLimiter):
|
|||||||
refill_period_tokens = 200
|
refill_period_tokens = 200
|
||||||
burst_tokens = 100
|
burst_tokens = 100
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def _init_pick_history(self) -> None:
|
||||||
# Load pick history from schema
|
"""
|
||||||
# (Remember API limits through restarts of Cartridges)
|
Load the pick history from schema.
|
||||||
|
|
||||||
|
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):
|
def acquire(self) -> None:
|
||||||
"""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())
|
||||||
@@ -90,7 +93,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) -> SteamManifestData:
|
def get_manifest_data(self, manifest_path: 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:
|
||||||
@@ -104,7 +107,11 @@ class SteamFileHelper:
|
|||||||
raise SteamInvalidManifestError()
|
raise SteamInvalidManifestError()
|
||||||
data[key] = match.group(1)
|
data[key] = match.group(1)
|
||||||
|
|
||||||
return SteamManifestData(**data)
|
return SteamManifestData(
|
||||||
|
name=data["name"],
|
||||||
|
appid=data["appid"],
|
||||||
|
stateflags=data["stateflags"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class SteamAPIHelper:
|
class SteamAPIHelper:
|
||||||
@@ -116,7 +123,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) -> SteamAPIData:
|
def get_api_data(self, appid: str) -> 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.
|
||||||
|
|||||||
@@ -20,12 +20,14 @@
|
|||||||
|
|
||||||
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
|
||||||
|
|
||||||
|
|
||||||
@@ -55,12 +57,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):
|
def auth_headers(self) -> dict[str, str]:
|
||||||
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):
|
def get_game_id(self, game: Game) -> Any:
|
||||||
"""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)
|
||||||
@@ -74,7 +76,7 @@ class SGDBHelper:
|
|||||||
case _:
|
case _:
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
|
|
||||||
def get_image_uri(self, game_id, animated=False):
|
def get_image_uri(self, game_id: str, animated: bool = False) -> Any:
|
||||||
"""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:
|
||||||
@@ -93,7 +95,7 @@ class SGDBHelper:
|
|||||||
case _:
|
case _:
|
||||||
res.raise_for_status()
|
res.raise_for_status()
|
||||||
|
|
||||||
def conditionaly_update_cover(self, game):
|
def conditionaly_update_cover(self, game: Game) -> None:
|
||||||
"""Update the game's cover if appropriate"""
|
"""Update the game's cover if appropriate"""
|
||||||
|
|
||||||
# Obvious skips
|
# Obvious skips
|
||||||
@@ -103,11 +105,11 @@ class SGDBHelper:
|
|||||||
|
|
||||||
image_trunk = shared.covers_dir / game.game_id
|
image_trunk = shared.covers_dir / game.game_id
|
||||||
still = image_trunk.with_suffix(".tiff")
|
still = image_trunk.with_suffix(".tiff")
|
||||||
uri_kwargs = image_trunk.with_suffix(".gif")
|
animated = image_trunk.with_suffix(".gif")
|
||||||
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
|
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
|
||||||
|
|
||||||
# Do nothing if file present and not prefer SGDB
|
# Do nothing if file present and not prefer SGDB
|
||||||
if not prefer_sgdb and (still.is_file() or uri_kwargs.is_file()):
|
if not prefer_sgdb and (still.is_file() or animated.is_file()):
|
||||||
return
|
return
|
||||||
|
|
||||||
# Get ID for the game
|
# Get ID for the game
|
||||||
|
|||||||
@@ -18,25 +18,28 @@
|
|||||||
# 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, data):
|
def create_task_thread_func_closure(func: Callable, data: Any) -> Callable:
|
||||||
"""Wrap a Gio.TaskThreadFunc with the given data in a closure"""
|
"""Wrap a Gio.TaskThreadFunc with the given data in a closure"""
|
||||||
|
|
||||||
def closure(task, source_object, _data, cancellable):
|
def closure(
|
||||||
|
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):
|
def decorate_set_task_data(task: Gio.Task) -> Callable:
|
||||||
"""Decorate Gio.Task.set_task_data to replace it"""
|
"""Decorate Gio.Task.set_task_data to replace it"""
|
||||||
|
|
||||||
def decorator(original_method):
|
def decorator(original_method: Callable) -> Callable:
|
||||||
@wraps(original_method)
|
@wraps(original_method)
|
||||||
def new_method(task_data):
|
def new_method(task_data: Any) -> None:
|
||||||
task.task_data = task_data
|
task.task_data = task_data
|
||||||
|
|
||||||
return new_method
|
return new_method
|
||||||
@@ -44,13 +47,13 @@ def decorate_set_task_data(task):
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
|
|
||||||
def decorate_run_in_thread(task):
|
def decorate_run_in_thread(task: Gio.Task) -> Callable:
|
||||||
"""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):
|
def decorator(original_method: Callable) -> Callable:
|
||||||
@wraps(original_method)
|
@wraps(original_method)
|
||||||
def new_method(task_thread_func):
|
def new_method(task_thread_func: Callable) -> None:
|
||||||
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)
|
||||||
|
|
||||||
@@ -64,11 +67,17 @@ 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(cls, source_object, cancellable, callback, callback_data):
|
def new(
|
||||||
|
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:
|
||||||
|
|||||||
@@ -17,9 +17,13 @@
|
|||||||
#
|
#
|
||||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
|
||||||
from gi.repository import Adw, Gtk
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
@@ -64,13 +68,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 = {}
|
game_covers: dict = {}
|
||||||
toasts = {}
|
toasts: dict = {}
|
||||||
active_game = None
|
active_game: Game
|
||||||
details_view_game_cover = None
|
details_view_game_cover: Optional[GameCover] = None
|
||||||
sort_state = "a-z"
|
sort_state: str = "a-z"
|
||||||
|
|
||||||
def __init__(self, **kwargs):
|
def __init__(self, **kwargs: Any) -> None:
|
||||||
super().__init__(**kwargs)
|
super().__init__(**kwargs)
|
||||||
|
|
||||||
self.previous_page = self.library_view
|
self.previous_page = self.library_view
|
||||||
@@ -110,11 +114,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, hidden):
|
def search_changed(self, _widget: Any, hidden: bool) -> None:
|
||||||
# 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):
|
def set_library_child(self) -> None:
|
||||||
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:
|
||||||
@@ -134,7 +138,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):
|
def filter_func(self, child: Gtk.Widget) -> bool:
|
||||||
game = child.get_child()
|
game = child.get_child()
|
||||||
text = (
|
text = (
|
||||||
(
|
(
|
||||||
@@ -156,10 +160,10 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
return not filtered
|
return not filtered
|
||||||
|
|
||||||
def set_active_game(self, _widget, _pspec, game):
|
def set_active_game(self, _widget: Any, _pspec: Any, game: Game) -> None:
|
||||||
self.active_game = game
|
self.active_game = game
|
||||||
|
|
||||||
def show_details_view(self, game):
|
def show_details_view(self, game: Game) -> None:
|
||||||
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))
|
||||||
@@ -207,7 +211,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
self.set_details_view_opacity()
|
self.set_details_view_opacity()
|
||||||
|
|
||||||
def set_details_view_opacity(self, *_args):
|
def set_details_view_opacity(self, *_args: Any) -> None:
|
||||||
if self.stack.get_visible_child() != self.details_view:
|
if self.stack.get_visible_child() != self.details_view:
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -218,12 +222,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]
|
1 - self.details_view_game_cover.luminance[0] # type: ignore
|
||||||
if style_manager.get_dark()
|
if style_manager.get_dark()
|
||||||
else self.details_view_game_cover.luminance[1]
|
else self.details_view_game_cover.luminance[1] # type: ignore
|
||||||
)
|
)
|
||||||
|
|
||||||
def sort_func(self, child1, child2):
|
def sort_func(self, child1: Gtk.Widget, child2: Gtk.Widget) -> int:
|
||||||
var, order = "name", True
|
var, order = "name", True
|
||||||
|
|
||||||
if self.sort_state in ("newest", "oldest"):
|
if self.sort_state in ("newest", "oldest"):
|
||||||
@@ -233,7 +237,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):
|
def get_value(index: int) -> str:
|
||||||
return str(
|
return str(
|
||||||
getattr((child1.get_child(), child2.get_child())[index], var)
|
getattr((child1.get_child(), child2.get_child())[index], var)
|
||||||
).lower()
|
).lower()
|
||||||
@@ -243,7 +247,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):
|
def navigate(self, next_page: Gtk.Widget) -> None:
|
||||||
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
|
||||||
@@ -260,13 +264,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):
|
def on_go_back_action(self, *_args: Any) -> None:
|
||||||
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):
|
def on_go_to_parent_action(self, *_args: Any) -> None:
|
||||||
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
|
||||||
@@ -274,20 +278,20 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
else self.library_view
|
else self.library_view
|
||||||
)
|
)
|
||||||
|
|
||||||
def on_go_home_action(self, *_args):
|
def on_go_home_action(self, *_args: Any) -> None:
|
||||||
self.navigate(self.library_view)
|
self.navigate(self.library_view)
|
||||||
|
|
||||||
def on_show_hidden_action(self, *_args):
|
def on_show_hidden_action(self, *_args: Any) -> None:
|
||||||
self.navigate(self.hidden_library_view)
|
self.navigate(self.hidden_library_view)
|
||||||
|
|
||||||
def on_sort_action(self, action, state):
|
def on_sort_action(self, action: Gio.SimpleAction, state: GLib.Variant) -> None:
|
||||||
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):
|
def on_toggle_search_action(self, *_args: Any) -> None:
|
||||||
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
|
||||||
@@ -304,7 +308,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
search_entry.set_text("")
|
search_entry.set_text("")
|
||||||
|
|
||||||
def on_escape_action(self, *_args):
|
def on_escape_action(self, *_args: Any) -> None:
|
||||||
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()
|
||||||
@@ -313,7 +317,7 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
else:
|
else:
|
||||||
self.on_go_back_action()
|
self.on_go_back_action()
|
||||||
|
|
||||||
def show_details_view_search(self, widget):
|
def show_details_view_search(self, widget: Gtk.Widget) -> None:
|
||||||
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
|
||||||
)
|
)
|
||||||
@@ -329,30 +333,39 @@ class CartridgesWindow(Adw.ApplicationWindow):
|
|||||||
|
|
||||||
index += 1
|
index += 1
|
||||||
|
|
||||||
def on_undo_action(self, _widget, game=None, undo=None):
|
def on_undo_action(
|
||||||
|
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 undo == "hide":
|
if game:
|
||||||
game.toggle_hidden(False)
|
if undo == "hide":
|
||||||
|
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):
|
def on_open_menu_action(self, *_args: Any) -> None:
|
||||||
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):
|
def on_close_action(self, *_args: Any) -> None:
|
||||||
self.close()
|
self.close()
|
||||||
|
|||||||
Reference in New Issue
Block a user