Compare commits

..

5 Commits

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

View File

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

View File

@@ -85,19 +85,6 @@ template $PreferencesWindow : Adw.PreferencesWindow {
title: _("Import");
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 {
title: _("Sources");
@@ -220,6 +207,20 @@ template $PreferencesWindow : Adw.PreferencesWindow {
}
}
Adw.ExpanderRow dolphin_expander_row {
title: _("Dolphin");
show-enable-switch: true;
Adw.ActionRow dolphin_cache_action_row {
title: _("Cache Location");
Button dolphin_cache_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
}
Adw.ExpanderRow itch_expander_row {
title: _("itch");
show-enable-switch: true;
@@ -248,20 +249,6 @@ template $PreferencesWindow : Adw.PreferencesWindow {
}
}
Adw.ExpanderRow retroarch_expander_row {
title: _("RetroArch");
show-enable-switch: true;
Adw.ActionRow retroarch_config_action_row {
title: _("Install Location");
Button retroarch_config_file_chooser_button {
icon-name: "folder-symbolic";
valign: center;
}
}
}
Adw.ExpanderRow flatpak_expander_row {
title: _("Flatpak");
show-enable-switch: true;

View File

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

View File

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

View File

@@ -10,9 +10,6 @@
<key name="high-quality-images" type="b">
<default>false</default>
</key>
<key name="remove-missing" type="b">
<default>true</default>
</key>
<key name="steam" type="b">
<default>true</default>
</key>
@@ -58,6 +55,12 @@
<key name="bottles-location" type="s">
<default>"~/.var/app/com.usebottles.bottles/data/bottles/"</default>
</key>
<key name="dolphin" type="b">
<default>true</default>
</key>
<key name="dolphin-cache-location" type="s">
<default>"~/.var/app/org.DolphinEmu.dolphin-emu/cache/dolphin-emu/"</default>
</key>
<key name="itch" type="b">
<default>true</default>
</key>
@@ -70,12 +73,6 @@
<key name="legendary-location" type="s">
<default>"~/.config/legendary/"</default>
</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">
<default>true</default>
</key>
@@ -122,4 +119,4 @@
<default>"[]"</default>
</key>
</schema>
</schemalist>
</schemalist>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -100,37 +100,33 @@ class LutrisSource(URLExecutableSource):
# 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
def game_id_format(self):
return self.source_id + "_{runner}_{game_id}"
def __init__(self) -> None:
super().__init__()
self.locations = LutrisLocations(
Location(
schema_key="lutris-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "data" / "lutris",
shared.data_dir / "lutris",
shared.home / ".local" / "share" / "lutris",
),
paths={
"pga.db": LocationSubPath("pga.db"),
},
invalid_subtitle=Location.DATA_INVALID_SUBTITLE,
),
Location(
schema_key="lutris-cache-location",
candidates=(
shared.flatpak_dir / "net.lutris.Lutris" / "cache" / "lutris",
shared.cache_dir / "lutris",
shared.home / ".cache" / "lutris",
),
paths={
"coverart": LocationSubPath("coverart", True),
},
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
),
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -41,7 +41,6 @@ games_dir = data_dir / "cartridges" / "games"
covers_dir = data_dir / "cartridges" / "covers"
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)")
scale_factor = max(

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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