Implement search provider (#201)
* Begin work on search provider * Initial search provider work, organize meson * Initial work on icons * Implement LaunchSearch * Don't hold arbitrary reference to service I don't know why Lollypop does this * Send notification, pad images * Update translations * Fix init_search_term typing
This commit is contained in:
1
cartridges/__builtins__.pyi
Normal file
1
cartridges/__builtins__.pyi
Normal file
@@ -0,0 +1 @@
|
||||
def _(_msg: str, /) -> str: ...
|
||||
58
cartridges/cartridges.in
Executable file
58
cartridges/cartridges.in
Executable file
@@ -0,0 +1,58 @@
|
||||
#!@PYTHON@
|
||||
|
||||
# cartridges.in
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import gettext
|
||||
import locale
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
|
||||
VERSION = "@VERSION@"
|
||||
if os.name == "nt":
|
||||
from ctypes import windll
|
||||
|
||||
os.environ["LANGUAGE"] = locale.windows_locale[
|
||||
windll.kernel32.GetUserDefaultUILanguage()
|
||||
]
|
||||
PKGDATADIR = os.path.join(os.path.dirname(__file__), "..", "share", "cartridges")
|
||||
LOCALEDIR = os.path.join(os.path.dirname(__file__), "..", "share", "locale")
|
||||
else:
|
||||
PKGDATADIR = "@pkgdatadir@"
|
||||
LOCALEDIR = "@localedir@"
|
||||
|
||||
sys.path.insert(1, PKGDATADIR)
|
||||
signal.signal(signal.SIGINT, signal.SIG_DFL)
|
||||
|
||||
if os.name != "nt":
|
||||
locale.bindtextdomain("cartridges", LOCALEDIR)
|
||||
locale.textdomain("cartridges")
|
||||
|
||||
gettext.install("cartridges", LOCALEDIR)
|
||||
|
||||
if __name__ == "__main__":
|
||||
from gi.repository import Gio
|
||||
|
||||
resource = Gio.Resource.load(os.path.join(PKGDATADIR, "cartridges.gresource"))
|
||||
resource._register() # pylint: disable=protected-access
|
||||
|
||||
from cartridges import main
|
||||
|
||||
sys.exit(main.main(VERSION))
|
||||
331
cartridges/details_window.py
Normal file
331
cartridges/details_window.py
Normal file
@@ -0,0 +1,331 @@
|
||||
# details_window.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
from cartridges.game_cover import GameCover
|
||||
from cartridges.store.managers.cover_manager import CoverManager
|
||||
from cartridges.store.managers.sgdb_manager import SgdbManager
|
||||
from cartridges.utils.create_dialog import create_dialog
|
||||
from cartridges.utils.save_cover import convert_cover, save_cover
|
||||
|
||||
|
||||
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/details-window.ui")
|
||||
class DetailsWindow(Adw.Window):
|
||||
__gtype_name__ = "DetailsWindow"
|
||||
|
||||
cover_overlay = Gtk.Template.Child()
|
||||
cover = Gtk.Template.Child()
|
||||
cover_button_edit = Gtk.Template.Child()
|
||||
cover_button_delete_revealer = Gtk.Template.Child()
|
||||
cover_button_delete = Gtk.Template.Child()
|
||||
spinner = Gtk.Template.Child()
|
||||
|
||||
name = Gtk.Template.Child()
|
||||
developer = Gtk.Template.Child()
|
||||
executable = Gtk.Template.Child()
|
||||
|
||||
exec_info_label = Gtk.Template.Child()
|
||||
exec_info_popover = Gtk.Template.Child()
|
||||
file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
apply_button = Gtk.Template.Child()
|
||||
|
||||
cover_changed: bool = False
|
||||
|
||||
def __init__(self, game: Optional[Game] = None, **kwargs: Any):
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.game: Game = game
|
||||
self.game_cover: GameCover = GameCover({self.cover})
|
||||
|
||||
self.set_transient_for(shared.win)
|
||||
|
||||
if self.game:
|
||||
self.set_title(_("Game Details"))
|
||||
self.name.set_text(self.game.name)
|
||||
if self.game.developer:
|
||||
self.developer.set_text(self.game.developer)
|
||||
self.executable.set_text(self.game.executable)
|
||||
self.apply_button.set_label(_("Apply"))
|
||||
|
||||
self.game_cover.new_cover(self.game.get_cover_path())
|
||||
if self.game_cover.get_texture():
|
||||
self.cover_button_delete_revealer.set_reveal_child(True)
|
||||
else:
|
||||
self.set_title(_("Add New Game"))
|
||||
self.apply_button.set_label(_("Add"))
|
||||
|
||||
image_filter = Gtk.FileFilter(name=_("Images"))
|
||||
for extension in Image.registered_extensions():
|
||||
image_filter.add_suffix(extension[1:])
|
||||
image_filter.add_suffix("svg") # Gdk.Texture supports .svg but PIL doesn't
|
||||
|
||||
image_filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||
image_filters.append(image_filter)
|
||||
|
||||
exec_filter = Gtk.FileFilter(name=_("Executables"))
|
||||
exec_filter.add_mime_type("application/x-executable")
|
||||
|
||||
exec_filters = Gio.ListStore.new(Gtk.FileFilter)
|
||||
exec_filters.append(exec_filter)
|
||||
|
||||
self.image_file_dialog = Gtk.FileDialog()
|
||||
self.image_file_dialog.set_filters(image_filters)
|
||||
self.image_file_dialog.set_default_filter(image_filter)
|
||||
|
||||
self.exec_file_dialog = Gtk.FileDialog()
|
||||
self.exec_file_dialog.set_filters(exec_filters)
|
||||
self.exec_file_dialog.set_default_filter(exec_filter)
|
||||
|
||||
# Translate this string as you would translate "file"
|
||||
file_name = _("file.txt")
|
||||
# As in software
|
||||
exe_name = _("program")
|
||||
|
||||
if os.name == "nt":
|
||||
exe_name += ".exe"
|
||||
# Translate this string as you would translate "path to {}"
|
||||
exe_path = _("C:\\path\\to\\{}").format(exe_name)
|
||||
# Translate this string as you would translate "path to {}"
|
||||
file_path = _("C:\\path\\to\\{}").format(file_name)
|
||||
command = "start"
|
||||
else:
|
||||
# Translate this string as you would translate "path to {}"
|
||||
exe_path = _("/path/to/{}").format(exe_name)
|
||||
# Translate this string as you would translate "path to {}"
|
||||
file_path = _("/path/to/{}").format(file_name)
|
||||
command = "xdg-open"
|
||||
|
||||
# pylint: disable=line-too-long
|
||||
exec_info_text = _(
|
||||
'To launch the executable "{}", use the command:\n\n<tt>"{}"</tt>\n\nTo open the file "{}" with the default application, use:\n\n<tt>{} "{}"</tt>\n\nIf the path contains spaces, make sure to wrap it in double quotes!'
|
||||
).format(exe_name, exe_path, file_name, command, file_path)
|
||||
|
||||
self.exec_info_label.set_label(exec_info_text)
|
||||
|
||||
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 set_exec_info_a11y_label(*_args: Any) -> None:
|
||||
self.set_focus(self.exec_info_popover)
|
||||
|
||||
self.exec_info_popover.connect("show", set_exec_info_a11y_label)
|
||||
|
||||
self.cover_button_delete.connect("clicked", self.delete_pixbuf)
|
||||
self.cover_button_edit.connect("clicked", self.choose_cover)
|
||||
self.file_chooser_button.connect("clicked", self.choose_executable)
|
||||
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.set_focus(self.name)
|
||||
self.present()
|
||||
|
||||
def delete_pixbuf(self, *_args: Any) -> None:
|
||||
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:
|
||||
final_name = self.name.get_text()
|
||||
final_developer = self.developer.get_text()
|
||||
final_executable = self.executable.get_text()
|
||||
|
||||
if not self.game:
|
||||
if final_name == "":
|
||||
create_dialog(
|
||||
self, _("Couldn't Add Game"), _("Game title cannot be empty.")
|
||||
)
|
||||
return
|
||||
|
||||
if final_executable == "":
|
||||
create_dialog(
|
||||
self, _("Couldn't Add Game"), _("Executable cannot be empty.")
|
||||
)
|
||||
return
|
||||
|
||||
# Increment the number after the game id (eg. imported_1, imported_2)
|
||||
source_id = "imported"
|
||||
numbers = [0]
|
||||
game_id: str
|
||||
for game_id in shared.store.source_games.get(source_id, set()):
|
||||
prefix = "imported_"
|
||||
if not game_id.startswith(prefix):
|
||||
continue
|
||||
numbers.append(int(game_id.replace(prefix, "", 1)))
|
||||
|
||||
game_number = max(numbers) + 1
|
||||
|
||||
self.game = Game(
|
||||
{
|
||||
"game_id": f"imported_{game_number}",
|
||||
"hidden": False,
|
||||
"source": source_id,
|
||||
"added": int(time()),
|
||||
}
|
||||
)
|
||||
|
||||
if shared.win.sidebar.get_selected_row().get_child() not in (
|
||||
shared.win.all_games_row_box,
|
||||
shared.win.added_row_box,
|
||||
):
|
||||
shared.win.sidebar.select_row(shared.win.added_row_box.get_parent())
|
||||
|
||||
else:
|
||||
if final_name == "":
|
||||
create_dialog(
|
||||
self,
|
||||
_("Couldn't Apply Preferences"),
|
||||
_("Game title cannot be empty."),
|
||||
)
|
||||
return
|
||||
|
||||
if final_executable == "":
|
||||
create_dialog(
|
||||
self,
|
||||
_("Couldn't Apply Preferences"),
|
||||
_("Executable cannot be empty."),
|
||||
)
|
||||
return
|
||||
|
||||
self.game.name = final_name
|
||||
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
|
||||
|
||||
shared.win.game_covers[self.game.game_id] = self.game_cover
|
||||
|
||||
if self.cover_changed:
|
||||
save_cover(
|
||||
self.game.game_id,
|
||||
self.game_cover.path,
|
||||
)
|
||||
|
||||
shared.store.add_game(self.game, {}, run_pipeline=False)
|
||||
self.game.save()
|
||||
self.game.update()
|
||||
|
||||
# TODO: this is fucked up (less than before)
|
||||
# Get a cover from SGDB if none is present
|
||||
if not self.game_cover.get_texture():
|
||||
self.game.set_loading(1)
|
||||
sgdb_manager = shared.store.managers[SgdbManager]
|
||||
sgdb_manager.reset_cancellable()
|
||||
sgdb_manager.process_game(self.game, {}, self.update_cover_callback)
|
||||
|
||||
self.game_cover.pictures.remove(self.cover)
|
||||
|
||||
self.close()
|
||||
shared.win.show_details_page(self.game)
|
||||
|
||||
def update_cover_callback(self, manager: SgdbManager) -> None:
|
||||
# Set the game as not loading
|
||||
self.game.set_loading(-1)
|
||||
self.game.update()
|
||||
|
||||
# Handle errors that occured
|
||||
for error in manager.collect_errors():
|
||||
# On auth error, inform the user
|
||||
if isinstance(error, FriendlyError):
|
||||
create_dialog(
|
||||
shared.win,
|
||||
error.title,
|
||||
error.subtitle,
|
||||
"open_preferences",
|
||||
_("Preferences"),
|
||||
).connect("response", self.update_cover_error_response)
|
||||
|
||||
def update_cover_error_response(self, _widget: Any, response: str) -> None:
|
||||
if response == "open_preferences":
|
||||
shared.win.get_application().on_preferences_action(page_name="sgdb")
|
||||
|
||||
def focus_executable(self, *_args: Any) -> None:
|
||||
self.set_focus(self.executable)
|
||||
|
||||
def toggle_loading(self) -> None:
|
||||
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:
|
||||
try:
|
||||
path = self.image_file_dialog.open_finish(result).get_path()
|
||||
except GLib.GError:
|
||||
return
|
||||
|
||||
def thread_func() -> None:
|
||||
new_path = None
|
||||
|
||||
try:
|
||||
with Image.open(path) as image:
|
||||
if getattr(image, "is_animated", False):
|
||||
new_path = convert_cover(path)
|
||||
except UnidentifiedImageError:
|
||||
pass
|
||||
|
||||
if not new_path:
|
||||
new_path = convert_cover(
|
||||
pixbuf=shared.store.managers[CoverManager].composite_cover(
|
||||
Path(path)
|
||||
)
|
||||
)
|
||||
|
||||
if new_path:
|
||||
self.game_cover.new_cover(new_path)
|
||||
self.cover_button_delete_revealer.set_reveal_child(True)
|
||||
self.cover_changed = True
|
||||
|
||||
self.toggle_loading()
|
||||
|
||||
self.toggle_loading()
|
||||
GLib.Thread.new(None, thread_func)
|
||||
|
||||
def set_executable(self, _source: Any, result: Gio.Task, *_args: Any) -> None:
|
||||
try:
|
||||
path = self.exec_file_dialog.open_finish(result).get_path()
|
||||
except GLib.GError:
|
||||
return
|
||||
|
||||
self.executable.set_text(shlex.quote(path))
|
||||
|
||||
def choose_executable(self, *_args: Any) -> None:
|
||||
self.exec_file_dialog.open(self, None, self.set_executable)
|
||||
|
||||
def choose_cover(self, *_args: Any) -> None:
|
||||
self.image_file_dialog.open(self, None, self.set_cover)
|
||||
28
cartridges/errors/error_producer.py
Normal file
28
cartridges/errors/error_producer.py
Normal file
@@ -0,0 +1,28 @@
|
||||
from threading import Lock
|
||||
|
||||
|
||||
class ErrorProducer:
|
||||
"""
|
||||
A mixin for objects that produce errors.
|
||||
|
||||
Specifies the report_error and collect_errors methods in a thread-safe manner.
|
||||
"""
|
||||
|
||||
errors: list[Exception]
|
||||
errors_lock: Lock
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.errors = []
|
||||
self.errors_lock = Lock()
|
||||
|
||||
def report_error(self, error: Exception) -> None:
|
||||
"""Report an error"""
|
||||
with self.errors_lock:
|
||||
self.errors.append(error)
|
||||
|
||||
def collect_errors(self) -> list[Exception]:
|
||||
"""Collect and remove the errors produced by the object"""
|
||||
with self.errors_lock:
|
||||
errors = self.errors.copy()
|
||||
self.errors.clear()
|
||||
return errors
|
||||
47
cartridges/errors/friendly_error.py
Normal file
47
cartridges/errors/friendly_error.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
class FriendlyError(Exception):
|
||||
"""
|
||||
An error that is supposed to be shown to the user in a nice format
|
||||
|
||||
Use `raise ... from ...` to preserve context.
|
||||
"""
|
||||
|
||||
title_format: str
|
||||
title_args: Iterable[str]
|
||||
subtitle_format: str
|
||||
subtitle_args: Iterable[str]
|
||||
|
||||
@property
|
||||
def title(self) -> str:
|
||||
"""Get the gettext translated error title"""
|
||||
return self.title_format.format(self.title_args)
|
||||
|
||||
@property
|
||||
def subtitle(self) -> str:
|
||||
"""Get the gettext translated error subtitle"""
|
||||
return self.subtitle_format.format(self.subtitle_args)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
title: str,
|
||||
subtitle: str,
|
||||
title_args: Optional[Iterable[str]] = None,
|
||||
subtitle_args: Optional[Iterable[str]] = None,
|
||||
) -> None:
|
||||
"""Create a friendly error
|
||||
|
||||
:param str title: The error's title, translatable with gettext
|
||||
:param str subtitle: The error's subtitle, translatable with gettext
|
||||
"""
|
||||
super().__init__()
|
||||
if title is not None:
|
||||
self.title_format = title
|
||||
if subtitle is not None:
|
||||
self.subtitle_format = subtitle
|
||||
self.title_args = title_args if title_args else ()
|
||||
self.subtitle_args = subtitle_args if subtitle_args else ()
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.title} - {self.subtitle}"
|
||||
203
cartridges/game.py
Normal file
203
cartridges/game.py
Normal file
@@ -0,0 +1,203 @@
|
||||
# game.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from gi.repository import Adw, GObject, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game_cover import GameCover
|
||||
from cartridges.utils.run_executable import run_executable
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/game.ui")
|
||||
class Game(Gtk.Box):
|
||||
__gtype_name__ = "Game"
|
||||
|
||||
title = Gtk.Template.Child()
|
||||
play_button = Gtk.Template.Child()
|
||||
cover = Gtk.Template.Child()
|
||||
spinner = Gtk.Template.Child()
|
||||
cover_button = Gtk.Template.Child()
|
||||
menu_button = Gtk.Template.Child()
|
||||
play_revealer = Gtk.Template.Child()
|
||||
menu_revealer = Gtk.Template.Child()
|
||||
game_options = Gtk.Template.Child()
|
||||
hidden_game_options = Gtk.Template.Child()
|
||||
|
||||
loading: int = 0
|
||||
filtered: bool = 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
|
||||
|
||||
def __init__(self, data: dict[str, Any], **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.app = shared.win.get_application()
|
||||
self.version = shared.SPEC_VERSION
|
||||
|
||||
self.update_values(data)
|
||||
self.base_source = self.source.split("_")[0]
|
||||
|
||||
self.set_play_icon()
|
||||
|
||||
self.event_contoller_motion = Gtk.EventControllerMotion.new()
|
||||
self.add_controller(self.event_contoller_motion)
|
||||
self.event_contoller_motion.connect("enter", self.toggle_play, False)
|
||||
self.event_contoller_motion.connect("leave", self.toggle_play, None, None)
|
||||
self.cover_button.connect("clicked", self.main_button_clicked, False)
|
||||
self.play_button.connect("clicked", self.main_button_clicked, True)
|
||||
|
||||
shared.schema.connect("changed", self.schema_changed)
|
||||
|
||||
def update_values(self, data: dict[str, Any]) -> None:
|
||||
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:
|
||||
self.emit("update-ready", {})
|
||||
|
||||
def save(self) -> None:
|
||||
self.emit("save-ready", {})
|
||||
|
||||
def create_toast(self, title: str, action: Optional[str] = None) -> None:
|
||||
toast = Adw.Toast.new(title.format(self.name))
|
||||
toast.set_priority(Adw.ToastPriority.HIGH)
|
||||
toast.set_use_markup(False)
|
||||
|
||||
if action:
|
||||
toast.set_button_label(_("Undo"))
|
||||
toast.connect("button-clicked", shared.win.on_undo_action, self, action)
|
||||
|
||||
if (self, action) in shared.win.toasts.keys():
|
||||
# Dismiss the toast if there already is one
|
||||
shared.win.toasts[(self, action)].dismiss()
|
||||
|
||||
shared.win.toasts[(self, action)] = toast
|
||||
|
||||
shared.win.toast_overlay.add_toast(toast)
|
||||
|
||||
def launch(self) -> None:
|
||||
self.last_played = int(time())
|
||||
self.save()
|
||||
self.update()
|
||||
|
||||
run_executable(self.executable)
|
||||
|
||||
if shared.schema.get_boolean("exit-after-launch"):
|
||||
self.app.quit()
|
||||
|
||||
# The variable is the title of the game
|
||||
self.create_toast(_("{} launched"))
|
||||
|
||||
def toggle_hidden(self, toast: bool = True) -> None:
|
||||
self.hidden = not self.hidden
|
||||
self.save()
|
||||
|
||||
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
|
||||
shared.win.navigation_view.pop()
|
||||
|
||||
self.update()
|
||||
|
||||
if toast:
|
||||
self.create_toast(
|
||||
# The variable is the title of the game
|
||||
(_("{} hidden") if self.hidden else _("{} unhidden")).format(self.name),
|
||||
"hide",
|
||||
)
|
||||
|
||||
def remove_game(self) -> None:
|
||||
# Add "removed=True" to the game properties so it can be deleted on next init
|
||||
self.removed = True
|
||||
self.save()
|
||||
self.update()
|
||||
|
||||
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
|
||||
shared.win.navigation_view.pop()
|
||||
|
||||
# The variable is the title of the game
|
||||
self.create_toast(_("{} removed").format(self.name), "remove")
|
||||
|
||||
def set_loading(self, state: int) -> None:
|
||||
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]:
|
||||
cover_path = shared.covers_dir / f"{self.game_id}.gif"
|
||||
if cover_path.is_file():
|
||||
return cover_path # type: ignore
|
||||
|
||||
cover_path = shared.covers_dir / f"{self.game_id}.tiff"
|
||||
if cover_path.is_file():
|
||||
return cover_path # type: ignore
|
||||
|
||||
return None
|
||||
|
||||
def toggle_play(
|
||||
self, _widget: Any, _prop1: Any, _prop2: Any, state: bool = True
|
||||
) -> None:
|
||||
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:
|
||||
if shared.schema.get_boolean("cover-launches-game") ^ button:
|
||||
self.launch()
|
||||
else:
|
||||
shared.win.show_details_page(self)
|
||||
|
||||
def set_play_icon(self) -> None:
|
||||
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:
|
||||
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
|
||||
"""Signal emitted when the game needs updating"""
|
||||
|
||||
@GObject.Signal(name="save-ready", arg_types=[object])
|
||||
def save_ready(self, _additional_data): # type: ignore
|
||||
"""Signal emitted when the game needs saving"""
|
||||
132
cartridges/game_cover.py
Normal file
132
cartridges/game_cover.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# game_cover.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk
|
||||
from PIL import Image, ImageFilter, ImageStat
|
||||
|
||||
from cartridges 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]
|
||||
|
||||
placeholder = Gdk.Texture.new_from_resource(
|
||||
shared.PREFIX + "/library_placeholder.svg"
|
||||
)
|
||||
placeholder_small = Gdk.Texture.new_from_resource(
|
||||
shared.PREFIX + "/library_placeholder_small.svg"
|
||||
)
|
||||
|
||||
def __init__(self, pictures: set[Gtk.Picture], path: Optional[Path] = None) -> None:
|
||||
self.pictures = pictures
|
||||
self.new_cover(path)
|
||||
|
||||
def new_cover(self, path: Optional[Path] = None) -> None:
|
||||
self.animation = None
|
||||
self.texture = None
|
||||
self.blurred = None
|
||||
self.luminance = None
|
||||
self.path = path
|
||||
|
||||
if path:
|
||||
if path.suffix == ".gif":
|
||||
self.animation = GdkPixbuf.PixbufAnimation.new_from_file(str(path))
|
||||
self.anim_iter = self.animation.get_iter()
|
||||
self.task = Gio.Task.new()
|
||||
self.task.run_in_thread(
|
||||
lambda *_: self.update_animation((self.task, self.animation))
|
||||
)
|
||||
else:
|
||||
self.texture = Gdk.Texture.new_from_filename(str(path))
|
||||
|
||||
if not self.animation:
|
||||
self.set_texture(self.texture)
|
||||
|
||||
def get_texture(self) -> Gdk.Texture:
|
||||
return (
|
||||
Gdk.Texture.new_for_pixbuf(self.animation.get_static_image())
|
||||
if self.animation
|
||||
else self.texture
|
||||
)
|
||||
|
||||
def get_blurred(self) -> Gdk.Texture:
|
||||
if not self.blurred:
|
||||
if self.path:
|
||||
with Image.open(self.path) as image:
|
||||
image = (
|
||||
image.convert("RGB")
|
||||
.resize((100, 150))
|
||||
.filter(ImageFilter.GaussianBlur(20))
|
||||
)
|
||||
|
||||
tmp_path = Gio.File.new_tmp(None)[0].get_path()
|
||||
image.save(tmp_path, "tiff", compression=None)
|
||||
|
||||
self.blurred = Gdk.Texture.new_from_filename(tmp_path)
|
||||
|
||||
stat = ImageStat.Stat(image.convert("L"))
|
||||
|
||||
# Luminance values for light and dark mode
|
||||
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:
|
||||
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:
|
||||
self.pictures.discard(
|
||||
picture for picture in self.pictures if not picture.is_visible()
|
||||
)
|
||||
if not self.pictures:
|
||||
self.animation = None
|
||||
else:
|
||||
for picture in self.pictures:
|
||||
picture.set_paintable(texture or self.placeholder)
|
||||
|
||||
def update_animation(self, data: GdkPixbuf.PixbufAnimation) -> None:
|
||||
if self.animation == data[1]:
|
||||
self.anim_iter.advance() # type: ignore
|
||||
|
||||
self.set_texture(Gdk.Texture.new_for_pixbuf(self.anim_iter.get_pixbuf())) # type: ignore
|
||||
|
||||
delay_time = self.anim_iter.get_delay_time() # type: ignore
|
||||
GLib.timeout_add(
|
||||
20 if delay_time < 20 else delay_time,
|
||||
self.update_animation,
|
||||
data,
|
||||
)
|
||||
109
cartridges/importer/bottles_source.py
Normal file
109
cartridges/importer/bottles_source.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# bottles_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import yaml
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import SourceIterable, URLExecutableSource
|
||||
|
||||
|
||||
class BottlesSourceIterable(SourceIterable):
|
||||
source: "BottlesSource"
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
|
||||
data = self.source.locations.data["library.yml"].read_text("utf-8")
|
||||
library: dict = yaml.safe_load(data)
|
||||
|
||||
for entry in library.values():
|
||||
# Build game
|
||||
values = {
|
||||
"source": self.source.source_id,
|
||||
"added": shared.import_time,
|
||||
"name": entry["name"],
|
||||
"game_id": self.source.game_id_format.format(game_id=entry["id"]),
|
||||
"executable": self.source.make_executable(
|
||||
bottle_name=entry["bottle"]["name"],
|
||||
game_name=entry["name"],
|
||||
),
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
# Get official cover path
|
||||
try:
|
||||
# This will not work if both Cartridges and Bottles are installed via Flatpak
|
||||
# as Cartridges can't access directories picked via Bottles' file picker portal
|
||||
bottles_location = Path(
|
||||
yaml.safe_load(
|
||||
self.source.locations.data["data.yml"].read_text("utf-8")
|
||||
)["custom_bottles_path"]
|
||||
)
|
||||
except (FileNotFoundError, KeyError):
|
||||
bottles_location = self.source.locations.data.root / "bottles"
|
||||
|
||||
bottle_path = entry["bottle"]["path"]
|
||||
|
||||
additional_data = {}
|
||||
if entry["thumbnail"]:
|
||||
image_name = entry["thumbnail"].split(":")[1]
|
||||
image_path = bottles_location / bottle_path / "grids" / image_name
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class BottlesLocations(NamedTuple):
|
||||
data: Location
|
||||
|
||||
|
||||
class BottlesSource(URLExecutableSource):
|
||||
"""Generic Bottles source"""
|
||||
|
||||
source_id = "bottles"
|
||||
name = _("Bottles")
|
||||
iterable_class = BottlesSourceIterable
|
||||
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,
|
||||
)
|
||||
)
|
||||
223
cartridges/importer/desktop_source.py
Normal file
223
cartridges/importer/desktop_source.py
Normal file
@@ -0,0 +1,223 @@
|
||||
# desktop_source.py
|
||||
#
|
||||
# Copyright 2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from gi.repository import GLib, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.source import Source, SourceIterable
|
||||
|
||||
|
||||
class DesktopSourceIterable(SourceIterable):
|
||||
source: "DesktopSource"
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
|
||||
icon_theme = Gtk.IconTheme.new()
|
||||
|
||||
search_paths = [
|
||||
shared.home / ".local" / "share",
|
||||
"/run/host/usr/local/share",
|
||||
"/run/host/usr/share",
|
||||
"/run/host/usr/share/pixmaps",
|
||||
"/usr/share/pixmaps",
|
||||
] + GLib.get_system_data_dirs()
|
||||
|
||||
for search_path in search_paths:
|
||||
path = Path(search_path)
|
||||
|
||||
if not str(search_path).endswith("/pixmaps"):
|
||||
path = path / "icons"
|
||||
|
||||
if not path.is_dir():
|
||||
continue
|
||||
|
||||
if str(path).startswith("/app/"):
|
||||
continue
|
||||
|
||||
icon_theme.add_search_path(str(path))
|
||||
|
||||
launch_command, full_path = self.check_launch_commands()
|
||||
|
||||
for path in search_paths:
|
||||
if str(path).startswith("/app/"):
|
||||
continue
|
||||
|
||||
path = Path(path) / "applications"
|
||||
|
||||
if not path.is_dir():
|
||||
continue
|
||||
|
||||
for entry in path.iterdir():
|
||||
if entry.suffix != ".desktop":
|
||||
continue
|
||||
|
||||
# Skip Lutris games
|
||||
if str(entry.name).startswith("net.lutris."):
|
||||
continue
|
||||
|
||||
keyfile = GLib.KeyFile.new()
|
||||
|
||||
try:
|
||||
keyfile.load_from_file(str(entry), 0)
|
||||
|
||||
if "Game" not in keyfile.get_string_list(
|
||||
"Desktop Entry", "Categories"
|
||||
):
|
||||
continue
|
||||
|
||||
name = keyfile.get_string("Desktop Entry", "Name")
|
||||
executable = keyfile.get_string("Desktop Entry", "Exec").split(
|
||||
" %"
|
||||
)[0]
|
||||
except GLib.GError:
|
||||
continue
|
||||
|
||||
try:
|
||||
try_exec = "which " + keyfile.get_string("Desktop Entry", "TryExec")
|
||||
if not self.check_command(try_exec):
|
||||
continue
|
||||
|
||||
except GLib.GError:
|
||||
pass
|
||||
|
||||
# Skip Steam games
|
||||
if "steam://rungameid/" in executable:
|
||||
continue
|
||||
|
||||
# Skip Heroic games
|
||||
if "heroic://launch/" in executable:
|
||||
continue
|
||||
|
||||
# Skip Bottles games
|
||||
if "bottles-cli " in executable:
|
||||
continue
|
||||
|
||||
try:
|
||||
if keyfile.get_boolean("Desktop Entry", "NoDisplay"):
|
||||
continue
|
||||
except GLib.GError:
|
||||
pass
|
||||
|
||||
try:
|
||||
if keyfile.get_boolean("Desktop Entry", "Hidden"):
|
||||
continue
|
||||
except GLib.GError:
|
||||
pass
|
||||
|
||||
# Strip /run/host from Flatpak paths
|
||||
if entry.is_relative_to(prefix := "/run/host"):
|
||||
entry = Path("/") / entry.relative_to(prefix)
|
||||
|
||||
launch_arg = shlex.quote(str(entry if full_path else entry.stem))
|
||||
|
||||
values = {
|
||||
"source": self.source.source_id,
|
||||
"added": shared.import_time,
|
||||
"name": name,
|
||||
"game_id": f"desktop_{entry.stem}",
|
||||
"executable": f"{launch_command} {launch_arg}",
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
additional_data = {}
|
||||
|
||||
try:
|
||||
icon_str = keyfile.get_string("Desktop Entry", "Icon")
|
||||
except GLib.GError:
|
||||
yield game
|
||||
continue
|
||||
else:
|
||||
if "/" in icon_str:
|
||||
additional_data = {"local_icon_path": Path(icon_str)}
|
||||
yield (game, additional_data)
|
||||
continue
|
||||
|
||||
try:
|
||||
if (
|
||||
icon_path := icon_theme.lookup_icon(
|
||||
icon_str,
|
||||
None,
|
||||
512,
|
||||
1,
|
||||
shared.win.get_direction(),
|
||||
0,
|
||||
)
|
||||
.get_file()
|
||||
.get_path()
|
||||
):
|
||||
additional_data = {"local_icon_path": Path(icon_path)}
|
||||
except GLib.GError:
|
||||
pass
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
def check_command(self, command) -> bool:
|
||||
flatpak_str = "flatpak-spawn --host /bin/sh -c "
|
||||
|
||||
if os.getenv("FLATPAK_ID") == shared.APP_ID:
|
||||
command = flatpak_str + shlex.quote(command)
|
||||
|
||||
try:
|
||||
subprocess.run(command, shell=True, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
return False
|
||||
|
||||
return True
|
||||
|
||||
def check_launch_commands(self) -> (str, bool):
|
||||
"""Check whether `gio launch` `gtk4-launch` or `gtk-launch` are available on the system"""
|
||||
commands = (("gio launch", True), ("gtk4-launch", False), ("gtk-launch", False))
|
||||
|
||||
for command, full_path in commands:
|
||||
# Even if `gio` is available, `gio launch` is only available on GLib >= 2.67.2
|
||||
command_to_check = (
|
||||
"gio help launch" if command == "gio launch" else f"which {command}"
|
||||
)
|
||||
|
||||
if self.check_command(command_to_check):
|
||||
return command, full_path
|
||||
|
||||
return commands[2]
|
||||
|
||||
|
||||
class DesktopLocations(NamedTuple):
|
||||
pass
|
||||
|
||||
|
||||
class DesktopSource(Source):
|
||||
"""Generic Flatpak source"""
|
||||
|
||||
source_id = "desktop"
|
||||
name = _("Desktop Entries")
|
||||
iterable_class = DesktopSourceIterable
|
||||
available_on = {"linux"}
|
||||
|
||||
locations: DesktopLocations
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.locations = DesktopLocations()
|
||||
140
cartridges/importer/flatpak_source.py
Normal file
140
cartridges/importer/flatpak_source.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# flatpak_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
from gi.repository import GLib, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import ExecutableFormatSource, SourceIterable
|
||||
|
||||
|
||||
class FlatpakSourceIterable(SourceIterable):
|
||||
source: "FlatpakSource"
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
|
||||
icon_theme = Gtk.IconTheme.new()
|
||||
icon_theme.add_search_path(str(self.source.locations.data["icons"]))
|
||||
|
||||
blacklist = (
|
||||
{"hu.kramo.Cartridges", "hu.kramo.Cartridges.Devel"}
|
||||
if shared.schema.get_boolean("flatpak-import-launchers")
|
||||
else {
|
||||
"hu.kramo.Cartridges",
|
||||
"hu.kramo.Cartridges.Devel",
|
||||
"com.valvesoftware.Steam",
|
||||
"net.lutris.Lutris",
|
||||
"com.heroicgameslauncher.hgl",
|
||||
"com.usebottles.Bottles",
|
||||
"io.itch.itch",
|
||||
"org.libretro.RetroArch",
|
||||
}
|
||||
)
|
||||
|
||||
for entry in (self.source.locations.data["applications"]).iterdir():
|
||||
if entry.suffix != ".desktop":
|
||||
continue
|
||||
|
||||
keyfile = GLib.KeyFile.new()
|
||||
|
||||
try:
|
||||
keyfile.load_from_file(str(entry), 0)
|
||||
|
||||
if "Game" not in keyfile.get_string_list("Desktop Entry", "Categories"):
|
||||
continue
|
||||
|
||||
if (
|
||||
flatpak_id := keyfile.get_string("Desktop Entry", "X-Flatpak")
|
||||
) in blacklist or flatpak_id != entry.stem:
|
||||
continue
|
||||
|
||||
name = keyfile.get_string("Desktop Entry", "Name")
|
||||
|
||||
except GLib.GError:
|
||||
continue
|
||||
|
||||
values = {
|
||||
"source": self.source.source_id,
|
||||
"added": shared.import_time,
|
||||
"name": name,
|
||||
"game_id": self.source.game_id_format.format(game_id=flatpak_id),
|
||||
"executable": self.source.make_executable(flatpak_id=flatpak_id),
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
additional_data = {}
|
||||
|
||||
try:
|
||||
if (
|
||||
icon_path := icon_theme.lookup_icon(
|
||||
keyfile.get_string("Desktop Entry", "Icon"),
|
||||
None,
|
||||
512,
|
||||
1,
|
||||
shared.win.get_direction(),
|
||||
0,
|
||||
)
|
||||
.get_file()
|
||||
.get_path()
|
||||
):
|
||||
additional_data = {"local_icon_path": Path(icon_path)}
|
||||
else:
|
||||
pass
|
||||
except GLib.GError:
|
||||
pass
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class FlatpakLocations(NamedTuple):
|
||||
data: Location
|
||||
|
||||
|
||||
class FlatpakSource(ExecutableFormatSource):
|
||||
"""Generic Flatpak source"""
|
||||
|
||||
source_id = "flatpak"
|
||||
name = _("Flatpak")
|
||||
iterable_class = FlatpakSourceIterable
|
||||
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,
|
||||
)
|
||||
)
|
||||
387
cartridges/importer/heroic_source.py
Normal file
387
cartridges/importer/heroic_source.py
Normal file
@@ -0,0 +1,387 @@
|
||||
# heroic_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from functools import cached_property
|
||||
from hashlib import sha256
|
||||
from json import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from typing import Iterable, NamedTuple, Optional, TypedDict
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import (
|
||||
SourceIterable,
|
||||
SourceIterationResult,
|
||||
URLExecutableSource,
|
||||
)
|
||||
|
||||
|
||||
def path_json_load(path: Path):
|
||||
"""
|
||||
Load JSON from the file at the given path
|
||||
|
||||
:raises OSError: if the file can't be opened
|
||||
:raises JSONDecodeError: if the file isn't valid JSON
|
||||
"""
|
||||
with path.open("r", encoding="utf-8") as open_file:
|
||||
return json.load(open_file)
|
||||
|
||||
|
||||
class InvalidLibraryFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidInstalledFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class InvalidStoreFileError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class HeroicLibraryEntry(TypedDict):
|
||||
app_name: str
|
||||
installed: Optional[bool]
|
||||
runner: str
|
||||
title: str
|
||||
developer: str
|
||||
art_square: str
|
||||
|
||||
|
||||
class SubSourceIterable(Iterable):
|
||||
"""Class representing a Heroic sub-source"""
|
||||
|
||||
source: "HeroicSource"
|
||||
source_iterable: "HeroicSourceIterable"
|
||||
name: str
|
||||
service: str
|
||||
image_uri_params: str = ""
|
||||
relative_library_path: Path
|
||||
library_json_entries_key: str = "library"
|
||||
|
||||
def __init__(self, source, source_iterable) -> None:
|
||||
self.source = source
|
||||
self.source_iterable = source_iterable
|
||||
|
||||
@cached_property
|
||||
def library_path(self) -> Path:
|
||||
path = self.source.locations.config.root / self.relative_library_path
|
||||
logging.debug("Using Heroic %s library.json path %s", self.name, path)
|
||||
return path
|
||||
|
||||
def process_library_entry(self, entry: HeroicLibraryEntry) -> SourceIterationResult:
|
||||
"""Build a Game from a Heroic library entry"""
|
||||
|
||||
app_name = entry["app_name"]
|
||||
runner = entry["runner"]
|
||||
|
||||
# Build game
|
||||
values = {
|
||||
"source": f"{self.source.source_id}_{self.service}",
|
||||
"added": shared.import_time,
|
||||
"name": entry["title"],
|
||||
"developer": entry.get("developer", None),
|
||||
"game_id": self.source.game_id_format.format(
|
||||
service=self.service, game_id=app_name
|
||||
),
|
||||
"executable": self.source.make_executable(runner=runner, app_name=app_name),
|
||||
"hidden": self.source_iterable.is_hidden(app_name),
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
# Get the image path from the Heroic cache
|
||||
# Filenames are derived from the URL that Heroic used to get the file
|
||||
uri: str = entry["art_square"] + self.image_uri_params
|
||||
digest = sha256(uri.encode()).hexdigest()
|
||||
image_path = self.source.locations.config.root / "images-cache" / digest
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
return (game, additional_data)
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Iterate through the games with a generator
|
||||
:raises InvalidLibraryFileError: on initial call if the library file is bad
|
||||
"""
|
||||
|
||||
try:
|
||||
iterator = iter(
|
||||
path_json_load(self.library_path)[self.library_json_entries_key]
|
||||
)
|
||||
except (OSError, JSONDecodeError, TypeError, KeyError) as error:
|
||||
raise InvalidLibraryFileError(
|
||||
f"Invalid {self.library_path.name}"
|
||||
) from error
|
||||
for entry in iterator:
|
||||
try:
|
||||
yield self.process_library_entry(entry)
|
||||
except KeyError as error:
|
||||
logging.warning(
|
||||
"Skipped invalid %s game %s",
|
||||
self.name,
|
||||
entry.get("app_name", "UNKNOWN"),
|
||||
exc_info=error,
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
class StoreSubSourceIterable(SubSourceIterable):
|
||||
"""
|
||||
Class representing a "store" sub source.
|
||||
Games can be installed or not, this class does the check accordingly.
|
||||
"""
|
||||
|
||||
relative_installed_path: Path
|
||||
installed_app_names: set[str]
|
||||
|
||||
@cached_property
|
||||
def installed_path(self) -> Path:
|
||||
path = self.source.locations.config.root / self.relative_installed_path
|
||||
logging.debug("Using Heroic %s installed.json path %s", self.name, path)
|
||||
return path
|
||||
|
||||
@abstractmethod
|
||||
def get_installed_app_names(self) -> set[str]:
|
||||
"""
|
||||
Get the sub source's installed app names as a set.
|
||||
|
||||
:raises InvalidInstalledFileError: if the installed file data cannot be read
|
||||
Whenever possible, `__cause__` is set with the original exception
|
||||
"""
|
||||
|
||||
def is_installed(self, app_name: str) -> bool:
|
||||
return app_name in self.installed_app_names
|
||||
|
||||
def process_library_entry(self, entry):
|
||||
# Skip games that are not installed
|
||||
app_name = entry["app_name"]
|
||||
if not self.is_installed(app_name):
|
||||
logging.warning(
|
||||
"Skipped %s game %s (%s): not installed",
|
||||
self.service,
|
||||
entry["title"],
|
||||
app_name,
|
||||
)
|
||||
return None
|
||||
# Process entry as normal
|
||||
return super().process_library_entry(entry)
|
||||
|
||||
def __iter__(self):
|
||||
"""
|
||||
Iterate through the installed games with a generator
|
||||
:raises InvalidLibraryFileError: on initial call if the library file is bad
|
||||
:raises InvalidInstalledFileError: on initial call if the installed file is bad
|
||||
"""
|
||||
self.installed_app_names = self.get_installed_app_names()
|
||||
yield from super().__iter__()
|
||||
|
||||
|
||||
class SideloadIterable(SubSourceIterable):
|
||||
name = "sideload"
|
||||
service = "sideload"
|
||||
relative_library_path = Path("sideload_apps") / "library.json"
|
||||
library_json_entries_key = "games"
|
||||
|
||||
|
||||
class LegendaryIterable(StoreSubSourceIterable):
|
||||
name = "legendary"
|
||||
service = "epic"
|
||||
image_uri_params = "?h=400&resize=1&w=300"
|
||||
relative_library_path = Path("store_cache") / "legendary_library.json"
|
||||
|
||||
# relative_installed_path = (
|
||||
# Path("legendary") / "legendaryConfig" / "legendary" / "installed.json"
|
||||
# )
|
||||
|
||||
@cached_property
|
||||
def installed_path(self) -> Path:
|
||||
"""Get the right path depending on the Heroic version"""
|
||||
# TODO after Heroic 2.9 has been out for a while
|
||||
# We should use the commented out relative_installed_path
|
||||
# and remove this property override.
|
||||
|
||||
heroic_config_path = self.source.locations.config.root
|
||||
# Heroic >= 2.9
|
||||
if (path := heroic_config_path / "legendaryConfig").is_dir():
|
||||
logging.debug("Using Heroic >= 2.9 legendary file")
|
||||
# Heroic <= 2.8
|
||||
elif heroic_config_path.is_relative_to(shared.flatpak_dir):
|
||||
# Heroic flatpak
|
||||
path = shared.flatpak_dir / "com.heroicgameslauncher.hgl" / "config"
|
||||
logging.debug("Using Heroic flatpak <= 2.8 legendary file")
|
||||
else:
|
||||
# Heroic native
|
||||
logging.debug("Using Heroic native <= 2.8 legendary file")
|
||||
path = shared.home / ".config"
|
||||
|
||||
path = path / "legendary" / "installed.json"
|
||||
logging.debug("Using Heroic %s installed.json path %s", self.name, path)
|
||||
return path
|
||||
|
||||
def get_installed_app_names(self):
|
||||
try:
|
||||
return set(path_json_load(self.installed_path).keys())
|
||||
except (OSError, JSONDecodeError, AttributeError) as error:
|
||||
raise InvalidInstalledFileError(
|
||||
f"Invalid {self.installed_path.name}"
|
||||
) from error
|
||||
|
||||
|
||||
class GogIterable(StoreSubSourceIterable):
|
||||
name = "gog"
|
||||
service = "gog"
|
||||
library_json_entries_key = "games"
|
||||
relative_library_path = Path("store_cache") / "gog_library.json"
|
||||
relative_installed_path = Path("gog_store") / "installed.json"
|
||||
|
||||
def get_installed_app_names(self):
|
||||
try:
|
||||
return {
|
||||
app_name
|
||||
for entry in path_json_load(self.installed_path)["installed"]
|
||||
if (app_name := entry.get("appName")) is not None
|
||||
}
|
||||
except (OSError, JSONDecodeError, KeyError, AttributeError) as error:
|
||||
raise InvalidInstalledFileError(
|
||||
f"Invalid {self.installed_path.name}"
|
||||
) from error
|
||||
|
||||
|
||||
class NileIterable(StoreSubSourceIterable):
|
||||
name = "nile"
|
||||
service = "amazon"
|
||||
relative_library_path = Path("store_cache") / "nile_library.json"
|
||||
relative_installed_path = Path("nile_config") / "nile" / "installed.json"
|
||||
|
||||
def get_installed_app_names(self):
|
||||
try:
|
||||
installed_json = path_json_load(self.installed_path)
|
||||
return {
|
||||
app_name
|
||||
for entry in installed_json
|
||||
if (app_name := entry.get("id")) is not None
|
||||
}
|
||||
except (OSError, JSONDecodeError, AttributeError) as error:
|
||||
raise InvalidInstalledFileError(
|
||||
f"Invalid {self.installed_path.name}"
|
||||
) from error
|
||||
|
||||
|
||||
class HeroicSourceIterable(SourceIterable):
|
||||
source: "HeroicSource"
|
||||
|
||||
hidden_app_names: set[str] = set()
|
||||
|
||||
def is_hidden(self, app_name: str) -> bool:
|
||||
return app_name in self.hidden_app_names
|
||||
|
||||
def get_hidden_app_names(self) -> set[str]:
|
||||
"""Get the hidden app names from store/config.json
|
||||
|
||||
:raises InvalidStoreFileError: if the store is invalid for some reason
|
||||
"""
|
||||
|
||||
try:
|
||||
store = path_json_load(self.source.locations.config["store_config.json"])
|
||||
self.hidden_app_names = {
|
||||
app_name
|
||||
for game in store["games"]["hidden"]
|
||||
if (app_name := game.get("appName")) is not None
|
||||
}
|
||||
except KeyError:
|
||||
logging.warning('No ["games"]["hidden"] key in Heroic store file')
|
||||
except (OSError, JSONDecodeError, TypeError) as error:
|
||||
logging.error("Invalid Heroic store file", exc_info=error)
|
||||
raise InvalidStoreFileError() from error
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games from all the Heroic sub-sources"""
|
||||
|
||||
self.get_hidden_app_names()
|
||||
|
||||
# Get games from the sub sources
|
||||
for sub_source_class in (
|
||||
SideloadIterable,
|
||||
LegendaryIterable,
|
||||
GogIterable,
|
||||
NileIterable,
|
||||
):
|
||||
sub_source = sub_source_class(self.source, self)
|
||||
|
||||
if not shared.schema.get_boolean("heroic-import-" + sub_source.service):
|
||||
logging.debug("Skipping Heroic %s: disabled", sub_source.service)
|
||||
continue
|
||||
try:
|
||||
sub_source_iterable = iter(sub_source)
|
||||
yield from sub_source_iterable
|
||||
except (InvalidLibraryFileError, InvalidInstalledFileError) as error:
|
||||
logging.error(
|
||||
"Skipping bad Heroic sub-source %s",
|
||||
sub_source.service,
|
||||
exc_info=error,
|
||||
)
|
||||
continue
|
||||
|
||||
|
||||
class HeroicLocations(NamedTuple):
|
||||
config: Location
|
||||
|
||||
|
||||
class HeroicSource(URLExecutableSource):
|
||||
"""Generic Heroic Games Launcher source"""
|
||||
|
||||
source_id = "heroic"
|
||||
name = _("Heroic")
|
||||
iterable_class = HeroicSourceIterable
|
||||
url_format = "heroic://launch/{runner}/{app_name}"
|
||||
available_on = {"linux", "win32"}
|
||||
|
||||
locations: HeroicLocations
|
||||
|
||||
@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,
|
||||
)
|
||||
)
|
||||
424
cartridges/importer/importer.py
Normal file
424
cartridges/importer/importer.py
Normal file
@@ -0,0 +1,424 @@
|
||||
# importer.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from time import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.errors.error_producer import ErrorProducer
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import UnresolvableLocationError
|
||||
from cartridges.importer.source import Source
|
||||
from cartridges.store.managers.async_manager import AsyncManager
|
||||
from cartridges.store.pipeline import Pipeline
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
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: Optional[Adw.Toast] = None
|
||||
|
||||
sources: set[Source]
|
||||
|
||||
n_source_tasks_created: int = 0
|
||||
n_source_tasks_done: int = 0
|
||||
n_pipelines_done: int = 0
|
||||
game_pipelines: set[Pipeline]
|
||||
|
||||
removed_game_ids: set[str]
|
||||
imported_game_ids: set[str]
|
||||
|
||||
close_req_id: int
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
|
||||
shared.import_time = int(time())
|
||||
|
||||
# TODO: make this stateful
|
||||
shared.store.new_game_ids = set()
|
||||
shared.store.duplicate_game_ids = set()
|
||||
|
||||
self.removed_game_ids = set()
|
||||
self.imported_game_ids = set()
|
||||
|
||||
self.game_pipelines = set()
|
||||
self.sources = set()
|
||||
|
||||
@property
|
||||
def n_games_added(self) -> int:
|
||||
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:
|
||||
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
|
||||
|
||||
@property
|
||||
def sources_progress(self) -> float:
|
||||
try:
|
||||
progress = self.n_source_tasks_done / self.n_source_tasks_created
|
||||
except ZeroDivisionError:
|
||||
progress = 0
|
||||
return progress
|
||||
|
||||
@property
|
||||
def finished(self) -> bool:
|
||||
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:
|
||||
self.sources.add(source)
|
||||
|
||||
def run(self) -> None:
|
||||
"""Use several Gio.Task to import games from added sources"""
|
||||
|
||||
shared.win.get_application().state = shared.AppState.IMPORT
|
||||
|
||||
if self.__class__.summary_toast:
|
||||
self.__class__.summary_toast.dismiss()
|
||||
|
||||
shared.win.get_application().lookup_action("import").set_enabled(False)
|
||||
shared.win.get_application().lookup_action("add_game").set_enabled(False)
|
||||
|
||||
self.create_dialog()
|
||||
|
||||
# Collect all errors and reset the cancellables for the managers
|
||||
# - Only one importer exists at any given time
|
||||
# - Every import starts fresh
|
||||
self.collect_errors()
|
||||
for manager in shared.store.managers.values():
|
||||
manager.collect_errors()
|
||||
if isinstance(manager, AsyncManager):
|
||||
manager.reset_cancellable()
|
||||
|
||||
for source in self.sources:
|
||||
logging.debug("Importing games from source %s", source.source_id)
|
||||
task = Gio.Task.new(None, None, self.source_callback, (source,))
|
||||
self.n_source_tasks_created += 1
|
||||
task.run_in_thread(
|
||||
lambda _task, _obj, _data, _cancellable, src=source: self.source_task_thread_func(
|
||||
(src,)
|
||||
)
|
||||
)
|
||||
|
||||
self.progress_changed_callback()
|
||||
|
||||
def create_dialog(self) -> None:
|
||||
"""Create the import dialog"""
|
||||
self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12)
|
||||
self.import_statuspage = Adw.StatusPage(
|
||||
title=_("Importing Games…"),
|
||||
child=self.progressbar,
|
||||
)
|
||||
self.import_dialog = Adw.Window(
|
||||
content=self.import_statuspage,
|
||||
modal=True,
|
||||
default_width=350,
|
||||
default_height=-1,
|
||||
transient_for=shared.win,
|
||||
deletable=False,
|
||||
)
|
||||
|
||||
self.close_req_id = self.import_dialog.connect(
|
||||
"close-request", lambda *_: shared.win.close()
|
||||
)
|
||||
|
||||
self.import_dialog.present()
|
||||
|
||||
def source_task_thread_func(self, data: tuple) -> None:
|
||||
"""Source import task code"""
|
||||
|
||||
source: Source
|
||||
source, *_rest = data
|
||||
|
||||
# Early exit if not available or not installed
|
||||
if not source.is_available:
|
||||
logging.info("Source %s skipped, not available", source.source_id)
|
||||
return
|
||||
try:
|
||||
iterator = iter(source)
|
||||
except UnresolvableLocationError:
|
||||
logging.info("Source %s skipped, bad location", source.source_id)
|
||||
return
|
||||
|
||||
# Get games from source
|
||||
logging.info("Scanning source %s", source.source_id)
|
||||
while True:
|
||||
# Handle exceptions raised when iterating
|
||||
try:
|
||||
iteration_result = next(iterator)
|
||||
except StopIteration:
|
||||
break
|
||||
except Exception as error: # pylint: disable=broad-exception-caught
|
||||
logging.exception("%s in %s", type(error).__name__, source.source_id)
|
||||
self.report_error(error)
|
||||
continue
|
||||
|
||||
# Handle the result depending on its type
|
||||
if isinstance(iteration_result, Game):
|
||||
game = iteration_result
|
||||
additional_data = {}
|
||||
elif isinstance(iteration_result, tuple):
|
||||
game, additional_data = iteration_result
|
||||
elif iteration_result is None:
|
||||
continue
|
||||
else:
|
||||
# Warn source implementers that an invalid type was produced
|
||||
# Should not happen on production code
|
||||
logging.warning(
|
||||
"%s produced an invalid iteration return type %s",
|
||||
source.source_id,
|
||||
type(iteration_result),
|
||||
)
|
||||
continue
|
||||
|
||||
# Register game
|
||||
pipeline: Pipeline = shared.store.add_game(game, additional_data)
|
||||
if pipeline is not None:
|
||||
logging.info("Imported %s (%s)", game.name, game.game_id)
|
||||
pipeline.connect("advanced", self.pipeline_advanced_callback)
|
||||
self.game_pipelines.add(pipeline)
|
||||
|
||||
def update_progressbar(self) -> None:
|
||||
"""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:
|
||||
"""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:
|
||||
"""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:
|
||||
"""
|
||||
Callback called when the import process has progressed
|
||||
|
||||
Triggered when:
|
||||
* All sources have been started
|
||||
* A source finishes
|
||||
* A pipeline finishes
|
||||
"""
|
||||
self.update_progressbar()
|
||||
if self.finished:
|
||||
self.import_callback()
|
||||
|
||||
def remove_games(self) -> None:
|
||||
"""Set removed to True for missing games"""
|
||||
if not shared.schema.get_boolean("remove-missing"):
|
||||
return
|
||||
|
||||
for game in shared.store:
|
||||
if game.removed:
|
||||
continue
|
||||
if game.source == "imported":
|
||||
continue
|
||||
if not shared.schema.get_boolean(game.base_source):
|
||||
continue
|
||||
if game.game_id in shared.store.duplicate_game_ids:
|
||||
continue
|
||||
if game.game_id in shared.store.new_game_ids:
|
||||
continue
|
||||
|
||||
logging.debug("Removing missing game %s (%s)", game.name, game.game_id)
|
||||
|
||||
game.removed = True
|
||||
game.save()
|
||||
game.update()
|
||||
self.removed_game_ids.add(game.game_id)
|
||||
|
||||
def import_callback(self) -> None:
|
||||
"""Callback called when importing has finished"""
|
||||
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()
|
||||
# Disconnect the close-request signal that closes the main window
|
||||
self.import_dialog.disconnect(self.close_req_id)
|
||||
self.import_dialog.close()
|
||||
self.__class__.summary_toast = self.create_summary_toast()
|
||||
self.create_error_dialog()
|
||||
shared.win.get_application().lookup_action("import").set_enabled(True)
|
||||
shared.win.get_application().lookup_action("add_game").set_enabled(True)
|
||||
shared.win.get_application().state = shared.AppState.DEFAULT
|
||||
shared.win.create_source_rows()
|
||||
|
||||
def create_error_dialog(self) -> None:
|
||||
"""Dialog containing all errors raised by importers"""
|
||||
|
||||
# Collect all errors that happened in the importer and the managers
|
||||
errors = []
|
||||
errors.extend(self.collect_errors())
|
||||
for manager in shared.store.managers.values():
|
||||
errors.extend(manager.collect_errors())
|
||||
|
||||
# Filter out non friendly errors
|
||||
errors = set(
|
||||
tuple(
|
||||
(error.title, error.subtitle)
|
||||
for error in (
|
||||
filter(lambda error: isinstance(error, FriendlyError), errors)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
# No error to display
|
||||
if not errors:
|
||||
self.timeout_toast()
|
||||
return
|
||||
|
||||
# Create error dialog
|
||||
dialog = Adw.MessageDialog()
|
||||
dialog.set_heading(_("Warning"))
|
||||
dialog.add_response("close", _("Dismiss"))
|
||||
dialog.add_response("open_preferences_import", _("Preferences"))
|
||||
dialog.set_default_response("open_preferences_import")
|
||||
dialog.connect("response", self.dialog_response_callback)
|
||||
dialog.set_transient_for(shared.win)
|
||||
|
||||
if len(errors) == 1:
|
||||
dialog.set_heading((error := next(iter(errors)))[0])
|
||||
dialog.set_body(error[1])
|
||||
else:
|
||||
# Display the errors in a list
|
||||
list_box = Gtk.ListBox()
|
||||
list_box.set_selection_mode(Gtk.SelectionMode.NONE)
|
||||
list_box.set_css_classes(["boxed-list"])
|
||||
list_box.set_margin_top(9)
|
||||
for error in errors:
|
||||
row = Adw.ActionRow.new()
|
||||
row.set_title(error[0])
|
||||
row.set_subtitle(error[1])
|
||||
list_box.append(row)
|
||||
dialog.set_body(_("The following errors occured during import:"))
|
||||
dialog.set_extra_child(list_box)
|
||||
|
||||
dialog.present()
|
||||
|
||||
def 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()
|
||||
if self.__class__.summary_toast:
|
||||
self.__class__.summary_toast.dismiss()
|
||||
|
||||
logging.info("Import undone")
|
||||
|
||||
def create_summary_toast(self) -> Adw.Toast:
|
||||
"""N games imported, removed toast"""
|
||||
|
||||
toast = Adw.Toast(timeout=0, priority=Adw.ToastPriority.HIGH)
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
elif self.n_games_added == 1:
|
||||
toast_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)
|
||||
|
||||
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:
|
||||
return shared.win.get_application().on_preferences_action(
|
||||
page_name=page_name, expander_row=expander_row
|
||||
)
|
||||
|
||||
def timeout_toast(self, *_args: Any) -> None:
|
||||
"""Manually timeout the toast after the user has dismissed all warnings"""
|
||||
if self.__class__.summary_toast:
|
||||
GLib.timeout_add_seconds(5, self.__class__.summary_toast.dismiss)
|
||||
|
||||
def dialog_response_callback(self, _widget: Any, response: str, *args: Any) -> None:
|
||||
"""Handle after-import dialogs callback"""
|
||||
logging.debug("After-import dialog response: %s (%s)", response, str(args))
|
||||
if response == "open_preferences":
|
||||
self.open_preferences(*args)
|
||||
elif response == "open_preferences_import":
|
||||
self.open_preferences(*args).connect("close-request", self.timeout_toast)
|
||||
else:
|
||||
self.timeout_toast()
|
||||
104
cartridges/importer/itch_source.py
Normal file
104
cartridges/importer/itch_source.py
Normal file
@@ -0,0 +1,104 @@
|
||||
# itch_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from shutil import rmtree
|
||||
from sqlite3 import connect
|
||||
from typing import NamedTuple
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import SourceIterable, URLExecutableSource
|
||||
from cartridges.utils.sqlite import copy_db
|
||||
|
||||
|
||||
class ItchSourceIterable(SourceIterable):
|
||||
source: "ItchSource"
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
|
||||
# Query the database
|
||||
db_request = """
|
||||
SELECT
|
||||
games.id,
|
||||
games.title,
|
||||
games.cover_url,
|
||||
games.still_cover_url,
|
||||
caves.id
|
||||
FROM
|
||||
'caves'
|
||||
INNER JOIN
|
||||
'games'
|
||||
ON
|
||||
caves.game_id = games.id
|
||||
;
|
||||
"""
|
||||
db_path = copy_db(self.source.locations.config["butler.db"])
|
||||
connection = connect(db_path)
|
||||
cursor = connection.execute(db_request)
|
||||
|
||||
# Create games from the db results
|
||||
for row in cursor:
|
||||
values = {
|
||||
"added": shared.import_time,
|
||||
"source": self.source.source_id,
|
||||
"name": row[1],
|
||||
"game_id": self.source.game_id_format.format(game_id=row[0]),
|
||||
"executable": self.source.make_executable(cave_id=row[4]),
|
||||
}
|
||||
additional_data = {"online_cover_url": row[3] or row[2]}
|
||||
game = Game(values)
|
||||
yield (game, additional_data)
|
||||
|
||||
# Cleanup
|
||||
rmtree(str(db_path.parent))
|
||||
|
||||
|
||||
class ItchLocations(NamedTuple):
|
||||
config: Location
|
||||
|
||||
|
||||
class ItchSource(URLExecutableSource):
|
||||
source_id = "itch"
|
||||
name = _("itch")
|
||||
iterable_class = ItchSourceIterable
|
||||
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,
|
||||
)
|
||||
)
|
||||
119
cartridges/importer/legendary_source.py
Normal file
119
cartridges/importer/legendary_source.py
Normal file
@@ -0,0 +1,119 @@
|
||||
# legendary_source.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import logging
|
||||
from json import JSONDecodeError
|
||||
from typing import NamedTuple
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import (
|
||||
ExecutableFormatSource,
|
||||
SourceIterable,
|
||||
SourceIterationResult,
|
||||
)
|
||||
|
||||
|
||||
class LegendarySourceIterable(SourceIterable):
|
||||
source: "LegendarySource"
|
||||
|
||||
def game_from_library_entry(self, entry: dict) -> SourceIterationResult:
|
||||
# Skip non-games
|
||||
if entry["is_dlc"]:
|
||||
return None
|
||||
|
||||
# Build game
|
||||
app_name = entry["app_name"]
|
||||
values = {
|
||||
"added": shared.import_time,
|
||||
"source": self.source.source_id,
|
||||
"name": entry["title"],
|
||||
"game_id": self.source.game_id_format.format(game_id=app_name),
|
||||
"executable": self.source.make_executable(app_name=app_name),
|
||||
}
|
||||
data = {}
|
||||
|
||||
# Get additional metadata from file (optional)
|
||||
metadata_file = self.source.locations.config["metadata"] / f"{app_name}.json"
|
||||
try:
|
||||
metadata = json.load(metadata_file.open())
|
||||
values["developer"] = metadata["metadata"]["developer"]
|
||||
for image_entry in metadata["metadata"]["keyImages"]:
|
||||
if image_entry["type"] == "DieselGameBoxTall":
|
||||
data["online_cover_url"] = image_entry["url"]
|
||||
break
|
||||
except (JSONDecodeError, OSError, KeyError):
|
||||
pass
|
||||
|
||||
game = Game(values)
|
||||
return (game, data)
|
||||
|
||||
def __iter__(self):
|
||||
# Open library
|
||||
file = self.source.locations.config["installed.json"]
|
||||
try:
|
||||
library: dict = json.load(file.open())
|
||||
except (JSONDecodeError, OSError):
|
||||
logging.warning("Couldn't open Legendary file: %s", str(file))
|
||||
return
|
||||
|
||||
# Generate games from library
|
||||
for entry in library.values():
|
||||
try:
|
||||
result = self.game_from_library_entry(entry)
|
||||
except KeyError as error:
|
||||
# Skip invalid games
|
||||
logging.warning(
|
||||
"Invalid Legendary game skipped in %s", str(file), exc_info=error
|
||||
)
|
||||
continue
|
||||
yield result
|
||||
|
||||
|
||||
class LegendaryLocations(NamedTuple):
|
||||
config: Location
|
||||
|
||||
|
||||
class LegendarySource(ExecutableFormatSource):
|
||||
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,
|
||||
)
|
||||
)
|
||||
102
cartridges/importer/location.py
Normal file
102
cartridges/importer/location.py
Normal file
@@ -0,0 +1,102 @@
|
||||
import logging
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Mapping, NamedTuple, Optional
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
PathSegment = str | PathLike | Path
|
||||
PathSegments = Iterable[PathSegment]
|
||||
Candidate = PathSegments
|
||||
|
||||
|
||||
class LocationSubPath(NamedTuple):
|
||||
segment: PathSegment
|
||||
is_directory: bool = False
|
||||
|
||||
|
||||
class UnresolvableLocationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Location:
|
||||
"""
|
||||
Class representing a filesystem location
|
||||
|
||||
* A location may have multiple candidate roots
|
||||
* The path in the schema is always favored
|
||||
* From the candidate root, multiple subpaths should exist for it to be valid
|
||||
* When resolved, the schema is updated with the picked chosen
|
||||
"""
|
||||
|
||||
# The variable is the name of the source
|
||||
CACHE_INVALID_SUBTITLE = _("Select the {} cache directory.")
|
||||
# The variable is the name of the source
|
||||
CONFIG_INVALID_SUBTITLE = _("Select the {} configuration directory.")
|
||||
# The variable is the name of the source
|
||||
DATA_INVALID_SUBTITLE = _("Select the {} data directory.")
|
||||
|
||||
schema_key: str
|
||||
candidates: Iterable[Candidate]
|
||||
paths: Mapping[str, LocationSubPath]
|
||||
invalid_subtitle: str
|
||||
|
||||
root: Optional[Path] = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
schema_key: str,
|
||||
candidates: Iterable[Candidate],
|
||||
paths: Mapping[str, LocationSubPath],
|
||||
invalid_subtitle: str,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.schema_key = schema_key
|
||||
self.candidates = candidates
|
||||
self.paths = paths
|
||||
self.invalid_subtitle = invalid_subtitle
|
||||
|
||||
def check_candidate(self, candidate: Path) -> bool:
|
||||
"""Check if a candidate root has the necessary files and directories"""
|
||||
for segment, is_directory in self.paths.values():
|
||||
path = Path(candidate) / segment
|
||||
if is_directory:
|
||||
if not path.is_dir():
|
||||
return False
|
||||
else:
|
||||
if not path.is_file():
|
||||
return False
|
||||
return True
|
||||
|
||||
def resolve(self) -> None:
|
||||
"""Choose a root path from the candidates for the location.
|
||||
If none fits, raise an UnresolvableLocationError"""
|
||||
|
||||
if self.root is not None:
|
||||
return
|
||||
|
||||
# Get the schema candidate
|
||||
schema_candidate = shared.schema.get_string(self.schema_key)
|
||||
|
||||
# Find the first matching candidate
|
||||
for candidate in (schema_candidate, *self.candidates):
|
||||
candidate = Path(candidate).expanduser()
|
||||
if not self.check_candidate(candidate):
|
||||
continue
|
||||
self.root = candidate
|
||||
break
|
||||
else:
|
||||
# No good candidate found
|
||||
raise UnresolvableLocationError()
|
||||
|
||||
# Update the schema with the found candidate
|
||||
value = str(candidate)
|
||||
shared.schema.set_string(self.schema_key, value)
|
||||
logging.debug("Resolved value for schema key %s: %s", self.schema_key, value)
|
||||
|
||||
def __getitem__(self, key: str) -> Optional[Path]:
|
||||
"""Get the computed path from its key for the location"""
|
||||
self.resolve()
|
||||
if self.root:
|
||||
return self.root / self.paths[key].segment
|
||||
return None
|
||||
132
cartridges/importer/lutris_source.py
Normal file
132
cartridges/importer/lutris_source.py
Normal file
@@ -0,0 +1,132 @@
|
||||
# lutris_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
from shutil import rmtree
|
||||
from sqlite3 import connect
|
||||
from typing import NamedTuple
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import SourceIterable, URLExecutableSource
|
||||
from cartridges.utils.sqlite import copy_db
|
||||
|
||||
|
||||
class LutrisSourceIterable(SourceIterable):
|
||||
source: "LutrisSource"
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
|
||||
# Query the database
|
||||
request = """
|
||||
SELECT id, name, slug, runner, hidden
|
||||
FROM 'games'
|
||||
WHERE
|
||||
name IS NOT NULL
|
||||
AND slug IS NOT NULL
|
||||
AND configPath IS NOT NULL
|
||||
AND installed
|
||||
AND (runner IS NOT "steam" OR :import_steam)
|
||||
AND (runner IS NOT "flatpak" OR :import_flatpak)
|
||||
;
|
||||
"""
|
||||
params = {
|
||||
"import_steam": shared.schema.get_boolean("lutris-import-steam"),
|
||||
"import_flatpak": shared.schema.get_boolean("lutris-import-flatpak"),
|
||||
}
|
||||
db_path = copy_db(self.source.locations.config["pga.db"])
|
||||
connection = connect(db_path)
|
||||
cursor = connection.execute(request, params)
|
||||
|
||||
# Create games from the DB results
|
||||
for row in cursor:
|
||||
# Create game
|
||||
values = {
|
||||
"added": shared.import_time,
|
||||
"hidden": row[4],
|
||||
"name": row[1],
|
||||
"source": f"{self.source.source_id}_{row[3]}",
|
||||
"game_id": self.source.game_id_format.format(
|
||||
runner=row[3], game_id=row[0]
|
||||
),
|
||||
"executable": self.source.make_executable(game_id=row[0]),
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
# Get official image path
|
||||
image_path = self.source.locations.cache["coverart"] / f"{row[2]}.jpg"
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
# Cleanup
|
||||
rmtree(str(db_path.parent))
|
||||
|
||||
|
||||
class LutrisLocations(NamedTuple):
|
||||
config: Location
|
||||
cache: Location
|
||||
|
||||
|
||||
class LutrisSource(URLExecutableSource):
|
||||
"""Generic Lutris source"""
|
||||
|
||||
source_id = "lutris"
|
||||
name = _("Lutris")
|
||||
iterable_class = LutrisSourceIterable
|
||||
url_format = "lutris:rungameid/{game_id}"
|
||||
available_on = {"linux"}
|
||||
|
||||
# FIXME possible bug: config picks ~/.var... and cache picks ~/.local...
|
||||
|
||||
locations: LutrisLocations
|
||||
|
||||
@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,
|
||||
),
|
||||
)
|
||||
255
cartridges/importer/retroarch_source.py
Normal file
255
cartridges/importer/retroarch_source.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# 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 typing import NamedTuple
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import (
|
||||
Location,
|
||||
LocationSubPath,
|
||||
UnresolvableLocationError,
|
||||
)
|
||||
from cartridges.importer.source import Source, SourceIterable
|
||||
from cartridges.importer.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):
|
||||
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": shared.import_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,
|
||||
)
|
||||
)
|
||||
# TODO enable when we get the Steam RetroArch games working
|
||||
# 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)
|
||||
# TODO enable when we get Steam RetroArch executable to work
|
||||
# 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)
|
||||
126
cartridges/importer/source.py
Normal file
126
cartridges/importer/source.py
Normal file
@@ -0,0 +1,126 @@
|
||||
# source.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable
|
||||
from typing import Any, Collection, Generator, Optional
|
||||
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location
|
||||
|
||||
# Type of the data returned by iterating on a Source
|
||||
SourceIterationResult = Optional[Game | tuple[Game, tuple[Any]]]
|
||||
|
||||
|
||||
class SourceIterable(Iterable):
|
||||
"""Data producer for a source of games"""
|
||||
|
||||
source: "Source"
|
||||
|
||||
def __init__(self, source: "Source") -> None:
|
||||
self.source = source
|
||||
|
||||
@abstractmethod
|
||||
def __iter__(self) -> Generator[SourceIterationResult, None, None]:
|
||||
"""
|
||||
Method that returns a generator that produces games
|
||||
* Should be implemented as a generator method
|
||||
* May yield `None` when an iteration hasn't produced a game
|
||||
* In charge of handling per-game errors
|
||||
* Returns when exhausted
|
||||
"""
|
||||
|
||||
|
||||
class Source(Iterable):
|
||||
"""Source of games. E.g an installed app with a config file that lists game directories"""
|
||||
|
||||
source_id: str
|
||||
name: str
|
||||
variant: Optional[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:
|
||||
full_name_ += f" ({self.variant})"
|
||||
return full_name_
|
||||
|
||||
@property
|
||||
def game_id_format(self) -> str:
|
||||
"""The string format used to construct game IDs"""
|
||||
return self.source_id + "_{game_id}"
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return sys.platform in self.available_on
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class URLExecutableSource(ExecutableFormatSource):
|
||||
"""Source class that use custom URLs to start games"""
|
||||
|
||||
url_format: str
|
||||
|
||||
@property
|
||||
def executable_format(self) -> str:
|
||||
match sys.platform:
|
||||
case "win32":
|
||||
return "start " + self.url_format
|
||||
case "linux":
|
||||
return "xdg-open " + self.url_format
|
||||
case other:
|
||||
raise NotImplementedError(
|
||||
f"No URL handler command available for {other}"
|
||||
)
|
||||
140
cartridges/importer/steam_source.py
Normal file
140
cartridges/importer/steam_source.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# steam_source.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Iterable, NamedTuple
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.location import Location, LocationSubPath
|
||||
from cartridges.importer.source import SourceIterable, URLExecutableSource
|
||||
from cartridges.utils.steam import SteamFileHelper, SteamInvalidManifestError
|
||||
|
||||
|
||||
class SteamSourceIterable(SourceIterable):
|
||||
source: "SteamSource"
|
||||
|
||||
def get_manifest_dirs(self) -> Iterable[Path]:
|
||||
"""Get dirs that contain Steam app manifests"""
|
||||
libraryfolders_path = self.source.locations.data["libraryfolders.vdf"]
|
||||
with open(libraryfolders_path, "r", encoding="utf-8") as file:
|
||||
contents = file.read()
|
||||
return [
|
||||
Path(path) / "steamapps"
|
||||
for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE)
|
||||
]
|
||||
|
||||
def get_manifests(self) -> Iterable[Path]:
|
||||
"""Get app manifests"""
|
||||
manifests = set()
|
||||
for steamapps_dir in self.get_manifest_dirs():
|
||||
if not steamapps_dir.is_dir():
|
||||
continue
|
||||
manifests.update(
|
||||
[
|
||||
manifest
|
||||
for manifest in steamapps_dir.glob("appmanifest_*.acf")
|
||||
if manifest.is_file()
|
||||
]
|
||||
)
|
||||
return manifests
|
||||
|
||||
def __iter__(self):
|
||||
"""Generator method producing games"""
|
||||
appid_cache = set()
|
||||
manifests = self.get_manifests()
|
||||
|
||||
for manifest in manifests:
|
||||
# Get metadata from manifest
|
||||
steam = SteamFileHelper()
|
||||
try:
|
||||
local_data = steam.get_manifest_data(manifest)
|
||||
except (OSError, SteamInvalidManifestError) as error:
|
||||
logging.debug("Couldn't load appmanifest %s", manifest, exc_info=error)
|
||||
continue
|
||||
|
||||
# Skip non installed games
|
||||
installed_mask = 4
|
||||
if not int(local_data["stateflags"]) & installed_mask:
|
||||
logging.debug("Skipped %s: not installed", manifest)
|
||||
continue
|
||||
|
||||
# Skip duplicate appids
|
||||
appid = local_data["appid"]
|
||||
if appid in appid_cache:
|
||||
logging.debug("Skipped %s: appid already seen during import", manifest)
|
||||
continue
|
||||
appid_cache.add(appid)
|
||||
|
||||
# Build game from local data
|
||||
values = {
|
||||
"added": shared.import_time,
|
||||
"name": local_data["name"],
|
||||
"source": self.source.source_id,
|
||||
"game_id": self.source.game_id_format.format(game_id=appid),
|
||||
"executable": self.source.make_executable(game_id=appid),
|
||||
}
|
||||
game = Game(values)
|
||||
|
||||
# Add official cover image
|
||||
image_path = (
|
||||
self.source.locations.data["librarycache"]
|
||||
/ f"{appid}_library_600x900.jpg"
|
||||
)
|
||||
additional_data = {"local_image_path": image_path, "steam_appid": appid}
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class SteamLocations(NamedTuple):
|
||||
data: Location
|
||||
|
||||
|
||||
class SteamSource(URLExecutableSource):
|
||||
source_id = "steam"
|
||||
name = _("Steam")
|
||||
available_on = {"linux", "win32"}
|
||||
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,
|
||||
)
|
||||
)
|
||||
44
cartridges/logging/color_log_formatter.py
Normal file
44
cartridges/logging/color_log_formatter.py
Normal file
@@ -0,0 +1,44 @@
|
||||
# color_log_formatter.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from logging import Formatter, LogRecord
|
||||
|
||||
|
||||
class ColorLogFormatter(Formatter):
|
||||
"""Formatter that outputs logs in a colored format"""
|
||||
|
||||
RESET = "\033[0m"
|
||||
DIM = "\033[2m"
|
||||
BOLD = "\033[1m"
|
||||
RED = "\033[31m"
|
||||
YELLOW = "\033[33m"
|
||||
|
||||
def format(self, record: LogRecord) -> str:
|
||||
super_format = super().format(record)
|
||||
match record.levelname:
|
||||
case "CRITICAL":
|
||||
return self.BOLD + self.RED + super_format + self.RESET
|
||||
case "ERROR":
|
||||
return self.RED + super_format + self.RESET
|
||||
case "WARNING":
|
||||
return self.YELLOW + super_format + self.RESET
|
||||
case "DEBUG":
|
||||
return self.DIM + super_format + self.RESET
|
||||
case _other:
|
||||
return super_format
|
||||
140
cartridges/logging/session_file_handler.py
Normal file
140
cartridges/logging/session_file_handler.py
Normal file
@@ -0,0 +1,140 @@
|
||||
# session_file_handler.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import lzma
|
||||
from io import TextIOWrapper
|
||||
from logging import StreamHandler
|
||||
from lzma import FORMAT_XZ, PRESET_DEFAULT
|
||||
from os import PathLike
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
|
||||
class SessionFileHandler(StreamHandler):
|
||||
"""
|
||||
A logging handler that writes to a new file on every app restart.
|
||||
The files are compressed and older sessions logs are kept up to a small limit.
|
||||
"""
|
||||
|
||||
NUMBER_SUFFIX_POSITION = 1
|
||||
|
||||
backup_count: int
|
||||
filename: Path
|
||||
log_file: Optional[TextIOWrapper] = None
|
||||
|
||||
def create_dir(self) -> None:
|
||||
"""Create the log dir if needed"""
|
||||
self.filename.parent.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
def path_is_logfile(self, path: Path) -> bool:
|
||||
return path.is_file() and path.name.startswith(self.filename.stem)
|
||||
|
||||
def path_has_number(self, path: Path) -> bool:
|
||||
try:
|
||||
int(path.suffixes[self.NUMBER_SUFFIX_POSITION][1:])
|
||||
except (ValueError, IndexError):
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_path_number(self, path: Path) -> int:
|
||||
"""Get the number extension in the filename as an int"""
|
||||
suffixes = path.suffixes
|
||||
number = (
|
||||
0
|
||||
if not self.path_has_number(path)
|
||||
else int(suffixes[self.NUMBER_SUFFIX_POSITION][1:])
|
||||
)
|
||||
return number
|
||||
|
||||
def set_path_number(self, path: Path, number: int) -> str:
|
||||
"""Set or add the number extension in the filename"""
|
||||
suffixes = path.suffixes
|
||||
if self.path_has_number(path):
|
||||
suffixes.pop(self.NUMBER_SUFFIX_POSITION)
|
||||
suffixes.insert(self.NUMBER_SUFFIX_POSITION, f".{number}")
|
||||
stem = path.name.split(".", maxsplit=1)[0]
|
||||
new_name = stem + "".join(suffixes)
|
||||
return new_name
|
||||
|
||||
def file_sort_key(self, path: Path) -> int:
|
||||
"""Key function used to sort files"""
|
||||
return self.get_path_number(path) if self.path_has_number(path) else 0
|
||||
|
||||
def get_logfiles(self) -> list[Path]:
|
||||
"""Get the log files"""
|
||||
logfiles = list(filter(self.path_is_logfile, self.filename.parent.iterdir()))
|
||||
logfiles.sort(key=self.file_sort_key, reverse=True)
|
||||
return logfiles
|
||||
|
||||
def rotate_file(self, path: Path) -> None:
|
||||
"""Rotate a file's number suffix and remove it if it's too old"""
|
||||
|
||||
# If uncompressed, compress
|
||||
if not path.name.endswith(".xz"):
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as original_file:
|
||||
original_data = original_file.read()
|
||||
except UnicodeDecodeError:
|
||||
# If the file is corrupted, throw it away
|
||||
path.unlink()
|
||||
return
|
||||
|
||||
# Compress the file
|
||||
compressed_path = path.with_suffix(path.suffix + ".xz")
|
||||
with lzma.open(
|
||||
compressed_path,
|
||||
"wt",
|
||||
format=FORMAT_XZ,
|
||||
preset=PRESET_DEFAULT,
|
||||
encoding="utf-8",
|
||||
) as lzma_file:
|
||||
lzma_file.write(original_data)
|
||||
path.unlink()
|
||||
path = compressed_path
|
||||
|
||||
# Rename with new number suffix
|
||||
new_number = self.get_path_number(path) + 1
|
||||
new_path_name = self.set_path_number(path, new_number)
|
||||
path = path.rename(path.with_name(new_path_name))
|
||||
|
||||
# Remove older files
|
||||
if new_number > self.backup_count:
|
||||
path.unlink()
|
||||
return
|
||||
|
||||
def rotate(self) -> None:
|
||||
"""Rotate the numbered suffix on the log files and remove old ones"""
|
||||
for path in self.get_logfiles():
|
||||
self.rotate_file(path)
|
||||
|
||||
def __init__(self, filename: PathLike, backup_count: int = 2) -> None:
|
||||
self.filename = Path(filename)
|
||||
self.backup_count = backup_count
|
||||
self.create_dir()
|
||||
self.rotate()
|
||||
self.log_file = open(self.filename, "w", encoding="utf-8")
|
||||
shared.log_files = self.get_logfiles()
|
||||
super().__init__(self.log_file)
|
||||
|
||||
def close(self) -> None:
|
||||
if self.log_file:
|
||||
self.log_file.close()
|
||||
super().close()
|
||||
109
cartridges/logging/setup.py
Normal file
109
cartridges/logging/setup.py
Normal file
@@ -0,0 +1,109 @@
|
||||
# setup.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
import logging.config as logging_dot_config
|
||||
import os
|
||||
import platform
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
|
||||
def setup_logging() -> None:
|
||||
"""Intitate the app's logging"""
|
||||
|
||||
is_dev = shared.PROFILE == "development"
|
||||
profile_app_log_level = "DEBUG" if is_dev else "INFO"
|
||||
profile_lib_log_level = "INFO" if is_dev else "WARNING"
|
||||
app_log_level = os.environ.get("LOGLEVEL", profile_app_log_level).upper()
|
||||
lib_log_level = os.environ.get("LIBLOGLEVEL", profile_lib_log_level).upper()
|
||||
|
||||
log_filename = shared.cache_dir / "cartridges" / "logs" / "cartridges.log"
|
||||
|
||||
config = {
|
||||
"version": 1,
|
||||
"formatters": {
|
||||
"file_formatter": {
|
||||
"format": "%(asctime)s - %(levelname)s: %(message)s",
|
||||
"datefmt": "%M:%S",
|
||||
},
|
||||
"console_formatter": {
|
||||
"format": "%(name)s %(levelname)s - %(message)s",
|
||||
"class": "cartridges.logging.color_log_formatter.ColorLogFormatter",
|
||||
},
|
||||
},
|
||||
"handlers": {
|
||||
"file_handler": {
|
||||
"class": "cartridges.logging.session_file_handler.SessionFileHandler",
|
||||
"formatter": "file_formatter",
|
||||
"level": "DEBUG",
|
||||
"filename": log_filename,
|
||||
"backup_count": 2,
|
||||
},
|
||||
"app_console_handler": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "console_formatter",
|
||||
"level": app_log_level,
|
||||
},
|
||||
"lib_console_handler": {
|
||||
"class": "logging.StreamHandler",
|
||||
"formatter": "console_formatter",
|
||||
"level": lib_log_level,
|
||||
},
|
||||
},
|
||||
"loggers": {
|
||||
"PIL": {
|
||||
"handlers": ["lib_console_handler", "file_handler"],
|
||||
"propagate": False,
|
||||
"level": "WARNING",
|
||||
},
|
||||
"urllib3": {
|
||||
"handlers": ["lib_console_handler", "file_handler"],
|
||||
"propagate": False,
|
||||
"level": "NOTSET",
|
||||
},
|
||||
},
|
||||
"root": {
|
||||
"level": "NOTSET",
|
||||
"handlers": ["app_console_handler", "file_handler"],
|
||||
},
|
||||
}
|
||||
logging_dot_config.dictConfig(config)
|
||||
|
||||
|
||||
def log_system_info() -> None:
|
||||
"""Log system debug information"""
|
||||
|
||||
logging.debug("Starting %s v%s (%s)", shared.APP_ID, shared.VERSION, shared.PROFILE)
|
||||
logging.debug("Python version: %s", sys.version)
|
||||
if os.getenv("FLATPAK_ID") == shared.APP_ID:
|
||||
process = subprocess.run(
|
||||
("flatpak-spawn", "--host", "flatpak", "--version"),
|
||||
capture_output=True,
|
||||
encoding="utf-8",
|
||||
check=False,
|
||||
)
|
||||
logging.debug("Flatpak version: %s", process.stdout.rstrip())
|
||||
logging.debug("Platform: %s", platform.platform())
|
||||
if os.name == "posix":
|
||||
for key, value in platform.uname()._asdict().items():
|
||||
logging.debug("\t%s: %s", key.title(), value)
|
||||
logging.debug("─" * 37)
|
||||
367
cartridges/main.py
Normal file
367
cartridges/main.py
Normal file
@@ -0,0 +1,367 @@
|
||||
# main.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import lzma
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from typing import Any, Optional
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version("Gtk", "4.0")
|
||||
gi.require_version("Adw", "1")
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.details_window import DetailsWindow
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.bottles_source import BottlesSource
|
||||
from cartridges.importer.desktop_source import DesktopSource
|
||||
from cartridges.importer.flatpak_source import FlatpakSource
|
||||
from cartridges.importer.heroic_source import HeroicSource
|
||||
from cartridges.importer.importer import Importer
|
||||
from cartridges.importer.itch_source import ItchSource
|
||||
from cartridges.importer.legendary_source import LegendarySource
|
||||
from cartridges.importer.lutris_source import LutrisSource
|
||||
from cartridges.importer.retroarch_source import RetroarchSource
|
||||
from cartridges.importer.steam_source import SteamSource
|
||||
from cartridges.logging.setup import log_system_info, setup_logging
|
||||
from cartridges.preferences import PreferencesWindow
|
||||
from cartridges.store.managers.cover_manager import CoverManager
|
||||
from cartridges.store.managers.display_manager import DisplayManager
|
||||
from cartridges.store.managers.file_manager import FileManager
|
||||
from cartridges.store.managers.sgdb_manager import SgdbManager
|
||||
from cartridges.store.managers.steam_api_manager import SteamAPIManager
|
||||
from cartridges.store.store import Store
|
||||
from cartridges.utils.migrate_files_v1_to_v2 import migrate_files_v1_to_v2
|
||||
from cartridges.utils.run_executable import run_executable
|
||||
from cartridges.window import CartridgesWindow
|
||||
|
||||
|
||||
class CartridgesApplication(Adw.Application):
|
||||
state = shared.AppState.DEFAULT
|
||||
win: CartridgesWindow
|
||||
init_search_term: Optional[str] = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
shared.store = Store()
|
||||
super().__init__(
|
||||
application_id=shared.APP_ID,
|
||||
flags=Gio.ApplicationFlags.HANDLES_COMMAND_LINE,
|
||||
)
|
||||
|
||||
def do_activate(self) -> None: # pylint: disable=arguments-differ
|
||||
"""Called on app creation"""
|
||||
|
||||
setup_logging()
|
||||
log_system_info()
|
||||
|
||||
if os.name == "nt":
|
||||
migrate_files_v1_to_v2()
|
||||
|
||||
# Set fallback icon-name
|
||||
Gtk.Window.set_default_icon_name(shared.APP_ID)
|
||||
|
||||
# Create the main window
|
||||
win = self.props.active_window # pylint: disable=no-member
|
||||
if not win:
|
||||
shared.win = win = CartridgesWindow(application=self)
|
||||
|
||||
# Save window geometry
|
||||
shared.state_schema.bind(
|
||||
"width", shared.win, "default-width", Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
shared.state_schema.bind(
|
||||
"height", shared.win, "default-height", Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
shared.state_schema.bind(
|
||||
"is-maximized", shared.win, "maximized", Gio.SettingsBindFlags.DEFAULT
|
||||
)
|
||||
|
||||
# Load games from disk
|
||||
shared.store.add_manager(FileManager(), False)
|
||||
shared.store.add_manager(DisplayManager())
|
||||
self.state = shared.AppState.LOAD_FROM_DISK
|
||||
self.load_games_from_disk()
|
||||
self.state = shared.AppState.DEFAULT
|
||||
shared.win.create_source_rows()
|
||||
|
||||
# Add rest of the managers for game imports
|
||||
shared.store.add_manager(CoverManager())
|
||||
shared.store.add_manager(SteamAPIManager())
|
||||
shared.store.add_manager(SgdbManager())
|
||||
shared.store.toggle_manager_in_pipelines(FileManager, True)
|
||||
|
||||
# Create actions
|
||||
self.create_actions(
|
||||
{
|
||||
("quit", ("<primary>q",)),
|
||||
("about",),
|
||||
("preferences", ("<primary>comma",)),
|
||||
("launch_game",),
|
||||
("hide_game",),
|
||||
("edit_game",),
|
||||
("add_game", ("<primary>n",)),
|
||||
("import", ("<primary>i",)),
|
||||
("remove_game_details_view", ("Delete",)),
|
||||
("remove_game",),
|
||||
("igdb_search",),
|
||||
("sgdb_search",),
|
||||
("protondb_search",),
|
||||
("lutris_search",),
|
||||
("hltb_search",),
|
||||
("show_sidebar", ("F9",), shared.win),
|
||||
("show_hidden", ("<primary>h",), shared.win),
|
||||
("go_to_parent", ("<alt>Up",), shared.win),
|
||||
("go_home", ("<alt>Home",), shared.win),
|
||||
("toggle_search", ("<primary>f",), shared.win),
|
||||
("escape", ("Escape",), shared.win),
|
||||
("undo", ("<primary>z",), shared.win),
|
||||
("open_menu", ("F10",), shared.win),
|
||||
("close", ("<primary>w",), shared.win),
|
||||
}
|
||||
)
|
||||
|
||||
sort_action = Gio.SimpleAction.new_stateful(
|
||||
"sort_by", GLib.VariantType.new("s"), GLib.Variant("s", "a-z")
|
||||
)
|
||||
sort_action.connect("activate", shared.win.on_sort_action)
|
||||
shared.win.add_action(sort_action)
|
||||
shared.win.on_sort_action(
|
||||
sort_action, shared.state_schema.get_value("sort-mode")
|
||||
)
|
||||
|
||||
if self.init_search_term: # For command line activation
|
||||
shared.win.search_bar.set_search_mode(True)
|
||||
shared.win.search_entry.set_text(self.init_search_term)
|
||||
shared.win.search_entry.set_position(-1)
|
||||
|
||||
shared.win.present()
|
||||
|
||||
def do_command_line(self, command_line) -> int:
|
||||
for index, arg in enumerate(args := command_line.get_arguments()):
|
||||
if arg == "--search":
|
||||
try:
|
||||
self.init_search_term = args[index + 1]
|
||||
except IndexError:
|
||||
pass
|
||||
break
|
||||
|
||||
if arg == "--launch":
|
||||
try:
|
||||
game_id = args[index + 1]
|
||||
data = json.load((shared.games_dir / (game_id + ".json")).open("r"))
|
||||
executable = (
|
||||
shlex.join(data["executable"])
|
||||
if isinstance(data["executable"], list)
|
||||
else data["executable"]
|
||||
)
|
||||
name = data["name"]
|
||||
|
||||
run_executable(executable)
|
||||
except (IndexError, KeyError, OSError, json.decoder.JSONDecodeError):
|
||||
return 1
|
||||
|
||||
notification = Gio.Notification.new(_("Cartridges"))
|
||||
notification.set_body(_("{} launched").format(name))
|
||||
self.send_notification(
|
||||
"launch",
|
||||
notification,
|
||||
)
|
||||
return 0
|
||||
self.activate()
|
||||
return 0
|
||||
|
||||
def load_games_from_disk(self) -> None:
|
||||
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
|
||||
game = Game(data)
|
||||
shared.store.add_game(game, {"skip_save": True})
|
||||
|
||||
def get_source_name(self, source_id: str) -> Any:
|
||||
if source_id == "all":
|
||||
name = _("All Games")
|
||||
elif source_id == "imported":
|
||||
name = _("Added")
|
||||
else:
|
||||
name = globals()[f'{source_id.split("_")[0].title()}Source'].name
|
||||
return name
|
||||
|
||||
def on_about_action(self, *_args: Any) -> None:
|
||||
# Get the debug info from the log files
|
||||
debug_str = ""
|
||||
for index, path in enumerate(shared.log_files):
|
||||
# Add a horizontal line between runs
|
||||
if index > 0:
|
||||
debug_str += "─" * 37 + "\n"
|
||||
# Add the run's logs
|
||||
log_file = (
|
||||
lzma.open(path, "rt", encoding="utf-8")
|
||||
if path.name.endswith(".xz")
|
||||
else open(path, "r", encoding="utf-8")
|
||||
)
|
||||
debug_str += log_file.read()
|
||||
log_file.close()
|
||||
|
||||
about = Adw.AboutWindow.new_from_appdata(
|
||||
shared.PREFIX + "/" + shared.APP_ID + ".metainfo.xml", shared.VERSION
|
||||
)
|
||||
about.set_transient_for(shared.win)
|
||||
about.set_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",
|
||||
"Rafael Mardojai CM https://mardojai.com",
|
||||
"Clara Hobbs https://github.com/Ratfink",
|
||||
)
|
||||
)
|
||||
about.set_designers(("kramo https://kramo.hu",))
|
||||
about.set_copyright("© 2022-2023 kramo")
|
||||
# Translators: Replace this with your name for it to show up in the about window
|
||||
about.set_translator_credits = (_("translator_credits"),)
|
||||
about.set_debug_info(debug_str)
|
||||
about.set_debug_info_filename("cartridges.log")
|
||||
about.add_legal_section(
|
||||
"Steam Branding",
|
||||
"© 2023 Valve Corporation",
|
||||
Gtk.License.CUSTOM,
|
||||
"Steam and the Steam logo are trademarks and/or registered trademarks of Valve Corporation in the U.S. and/or other countries.", # pylint: disable=line-too-long
|
||||
)
|
||||
about.present()
|
||||
|
||||
def on_preferences_action(
|
||||
self,
|
||||
_action: Any = None,
|
||||
_parameter: Any = None,
|
||||
page_name: Optional[str] = None,
|
||||
expander_row: Optional[str] = None,
|
||||
) -> CartridgesWindow:
|
||||
win = PreferencesWindow()
|
||||
if page_name:
|
||||
win.set_visible_page_name(page_name)
|
||||
if expander_row:
|
||||
getattr(win, expander_row).set_expanded(True)
|
||||
win.present()
|
||||
|
||||
return win
|
||||
|
||||
def on_launch_game_action(self, *_args: Any) -> None:
|
||||
shared.win.active_game.launch()
|
||||
|
||||
def on_hide_game_action(self, *_args: Any) -> None:
|
||||
shared.win.active_game.toggle_hidden()
|
||||
|
||||
def on_edit_game_action(self, *_args: Any) -> None:
|
||||
DetailsWindow(shared.win.active_game)
|
||||
|
||||
def on_add_game_action(self, *_args: Any) -> None:
|
||||
DetailsWindow()
|
||||
|
||||
def on_import_action(self, *_args: Any) -> None:
|
||||
shared.importer = Importer()
|
||||
|
||||
if shared.schema.get_boolean("lutris"):
|
||||
shared.importer.add_source(LutrisSource())
|
||||
|
||||
if shared.schema.get_boolean("steam"):
|
||||
shared.importer.add_source(SteamSource())
|
||||
|
||||
if shared.schema.get_boolean("heroic"):
|
||||
shared.importer.add_source(HeroicSource())
|
||||
|
||||
if shared.schema.get_boolean("bottles"):
|
||||
shared.importer.add_source(BottlesSource())
|
||||
|
||||
if shared.schema.get_boolean("flatpak"):
|
||||
shared.importer.add_source(FlatpakSource())
|
||||
|
||||
if shared.schema.get_boolean("desktop"):
|
||||
shared.importer.add_source(DesktopSource())
|
||||
|
||||
if shared.schema.get_boolean("itch"):
|
||||
shared.importer.add_source(ItchSource())
|
||||
|
||||
if shared.schema.get_boolean("legendary"):
|
||||
shared.importer.add_source(LegendarySource())
|
||||
|
||||
if shared.schema.get_boolean("retroarch"):
|
||||
shared.importer.add_source(RetroarchSource())
|
||||
|
||||
shared.importer.run()
|
||||
|
||||
def on_remove_game_action(self, *_args: Any) -> None:
|
||||
shared.win.active_game.remove_game()
|
||||
|
||||
def on_remove_game_details_view_action(self, *_args: Any) -> None:
|
||||
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
|
||||
self.on_remove_game_action()
|
||||
|
||||
def search(self, uri: str) -> None:
|
||||
Gio.AppInfo.launch_default_for_uri(f"{uri}{shared.win.active_game.name}")
|
||||
|
||||
def on_igdb_search_action(self, *_args: Any) -> None:
|
||||
self.search("https://www.igdb.com/search?type=1&q=")
|
||||
|
||||
def on_sgdb_search_action(self, *_args: Any) -> None:
|
||||
self.search("https://www.steamgriddb.com/search/grids?term=")
|
||||
|
||||
def on_protondb_search_action(self, *_args: Any) -> None:
|
||||
self.search("https://www.protondb.com/search?q=")
|
||||
|
||||
def on_lutris_search_action(self, *_args: Any) -> None:
|
||||
self.search("https://lutris.net/games?q=")
|
||||
|
||||
def on_hltb_search_action(self, *_args: Any) -> None:
|
||||
self.search("https://howlongtobeat.com/?q=")
|
||||
|
||||
def on_quit_action(self, *_args: Any) -> None:
|
||||
self.quit()
|
||||
|
||||
def create_actions(self, actions: set) -> None:
|
||||
for action in actions:
|
||||
simple_action = Gio.SimpleAction.new(action[0], None)
|
||||
|
||||
scope = action[2] if action[2:3] else self
|
||||
simple_action.connect("activate", getattr(scope, f"on_{action[0]}_action"))
|
||||
|
||||
if action[1:2]:
|
||||
self.set_accels_for_action(
|
||||
f"app.{action[0]}" if scope == self else f"win.{action[0]}",
|
||||
action[1],
|
||||
)
|
||||
|
||||
scope.add_action(simple_action)
|
||||
|
||||
|
||||
def main(_version: int) -> Any:
|
||||
"""App entry point"""
|
||||
app = CartridgesApplication()
|
||||
return app.run(sys.argv)
|
||||
31
cartridges/meson.build
Normal file
31
cartridges/meson.build
Normal file
@@ -0,0 +1,31 @@
|
||||
moduledir = join_paths(python_dir, 'cartridges')
|
||||
|
||||
configure_file(
|
||||
input: 'cartridges.in',
|
||||
output: 'cartridges',
|
||||
configuration: conf,
|
||||
install: true,
|
||||
install_dir: get_option('bindir')
|
||||
)
|
||||
|
||||
install_subdir('importer', install_dir: moduledir)
|
||||
install_subdir('utils', install_dir: moduledir)
|
||||
install_subdir('store', install_dir: moduledir)
|
||||
install_subdir('logging', install_dir: moduledir)
|
||||
install_subdir('errors', install_dir: moduledir)
|
||||
install_data(
|
||||
[
|
||||
'main.py',
|
||||
'window.py',
|
||||
'preferences.py',
|
||||
'details_window.py',
|
||||
'game.py',
|
||||
'game_cover.py',
|
||||
configure_file(
|
||||
input: 'shared.py.in',
|
||||
output: 'shared.py',
|
||||
configuration: conf
|
||||
)
|
||||
],
|
||||
install_dir: moduledir
|
||||
)
|
||||
440
cartridges/preferences.py
Normal file
440
cartridges/preferences.py
Normal file
@@ -0,0 +1,440 @@
|
||||
# preferences.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import 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 cartridges import shared
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
from cartridges.importer.bottles_source import BottlesSource
|
||||
from cartridges.importer.flatpak_source import FlatpakSource
|
||||
from cartridges.importer.heroic_source import HeroicSource
|
||||
from cartridges.importer.itch_source import ItchSource
|
||||
from cartridges.importer.legendary_source import LegendarySource
|
||||
from cartridges.importer.location import UnresolvableLocationError
|
||||
from cartridges.importer.lutris_source import LutrisSource
|
||||
from cartridges.importer.retroarch_source import RetroarchSource
|
||||
from cartridges.importer.source import Source
|
||||
from cartridges.importer.steam_source import SteamSource
|
||||
from cartridges.store.managers.sgdb_manager import SgdbManager
|
||||
from cartridges.utils.create_dialog import create_dialog
|
||||
|
||||
|
||||
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/preferences.ui")
|
||||
class PreferencesWindow(Adw.PreferencesWindow):
|
||||
__gtype_name__ = "PreferencesWindow"
|
||||
|
||||
general_page = Gtk.Template.Child()
|
||||
import_page = Gtk.Template.Child()
|
||||
sgdb_page = Gtk.Template.Child()
|
||||
|
||||
sources_group = Gtk.Template.Child()
|
||||
|
||||
exit_after_launch_switch = Gtk.Template.Child()
|
||||
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()
|
||||
|
||||
lutris_expander_row = Gtk.Template.Child()
|
||||
lutris_data_action_row = Gtk.Template.Child()
|
||||
lutris_data_file_chooser_button = Gtk.Template.Child()
|
||||
lutris_cache_action_row = Gtk.Template.Child()
|
||||
lutris_cache_file_chooser_button = Gtk.Template.Child()
|
||||
lutris_import_steam_switch = Gtk.Template.Child()
|
||||
lutris_import_flatpak_switch = Gtk.Template.Child()
|
||||
|
||||
heroic_expander_row = Gtk.Template.Child()
|
||||
heroic_config_action_row = Gtk.Template.Child()
|
||||
heroic_config_file_chooser_button = Gtk.Template.Child()
|
||||
heroic_import_epic_switch = Gtk.Template.Child()
|
||||
heroic_import_gog_switch = Gtk.Template.Child()
|
||||
heroic_import_amazon_switch = Gtk.Template.Child()
|
||||
heroic_import_sideload_switch = Gtk.Template.Child()
|
||||
|
||||
bottles_expander_row = Gtk.Template.Child()
|
||||
bottles_data_action_row = Gtk.Template.Child()
|
||||
bottles_data_file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
itch_expander_row = Gtk.Template.Child()
|
||||
itch_config_action_row = Gtk.Template.Child()
|
||||
itch_config_file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
legendary_expander_row = Gtk.Template.Child()
|
||||
legendary_config_action_row = Gtk.Template.Child()
|
||||
legendary_config_file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
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()
|
||||
flatpak_import_launchers_switch = Gtk.Template.Child()
|
||||
|
||||
desktop_switch = Gtk.Template.Child()
|
||||
|
||||
sgdb_key_group = Gtk.Template.Child()
|
||||
sgdb_key_entry_row = Gtk.Template.Child()
|
||||
sgdb_switch = Gtk.Template.Child()
|
||||
sgdb_prefer_switch = Gtk.Template.Child()
|
||||
sgdb_animated_switch = Gtk.Template.Child()
|
||||
sgdb_fetch_button = Gtk.Template.Child()
|
||||
|
||||
danger_zone_group = Gtk.Template.Child()
|
||||
reset_action_row = Gtk.Template.Child()
|
||||
reset_button = Gtk.Template.Child()
|
||||
remove_all_games_button = Gtk.Template.Child()
|
||||
|
||||
removed_games: set[Game] = set()
|
||||
warning_menu_buttons: dict = {}
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
self.file_chooser = Gtk.FileDialog()
|
||||
self.set_transient_for(shared.win)
|
||||
|
||||
self.toast = Adw.Toast.new(_("All games removed"))
|
||||
self.toast.set_button_label(_("Undo"))
|
||||
self.toast.connect("button-clicked", self.undo_remove_all, None)
|
||||
self.toast.set_priority(Adw.ToastPriority.HIGH)
|
||||
|
||||
(shortcut_controller := Gtk.ShortcutController()).add_shortcut(
|
||||
Gtk.Shortcut.new(
|
||||
Gtk.ShortcutTrigger.parse_string("<primary>z"),
|
||||
Gtk.CallbackAction.new(self.undo_remove_all),
|
||||
)
|
||||
)
|
||||
self.add_controller(shortcut_controller)
|
||||
|
||||
# General
|
||||
self.remove_all_games_button.connect("clicked", self.remove_all_games)
|
||||
|
||||
# Debug
|
||||
if shared.PROFILE == "development":
|
||||
self.reset_action_row.set_visible(True)
|
||||
self.reset_button.connect("clicked", self.reset_app)
|
||||
|
||||
# Sources settings
|
||||
for source_class in (
|
||||
BottlesSource,
|
||||
FlatpakSource,
|
||||
HeroicSource,
|
||||
ItchSource,
|
||||
LegendarySource,
|
||||
LutrisSource,
|
||||
RetroarchSource,
|
||||
SteamSource,
|
||||
):
|
||||
source = source_class()
|
||||
if not source.is_available:
|
||||
expander_row = getattr(self, f"{source.source_id}_expander_row")
|
||||
expander_row.set_visible(False)
|
||||
else:
|
||||
self.init_source_row(source)
|
||||
|
||||
# SteamGridDB
|
||||
def sgdb_key_changed(*_args: Any) -> None:
|
||||
shared.schema.set_string("sgdb-key", self.sgdb_key_entry_row.get_text())
|
||||
|
||||
self.sgdb_key_entry_row.set_text(shared.schema.get_string("sgdb-key"))
|
||||
self.sgdb_key_entry_row.connect("changed", sgdb_key_changed)
|
||||
|
||||
self.sgdb_key_group.set_description(
|
||||
_(
|
||||
"An API key is required to use SteamGridDB. You can generate one {}here{}."
|
||||
).format(
|
||||
'<a href="https://www.steamgriddb.com/profile/preferences/api">', "</a>"
|
||||
)
|
||||
)
|
||||
|
||||
def update_sgdb(*_args: Any) -> None:
|
||||
counter = 0
|
||||
games_len = len(shared.store)
|
||||
sgdb_manager = shared.store.managers[SgdbManager]
|
||||
sgdb_manager.reset_cancellable()
|
||||
|
||||
self.add_toast(download_toast := Adw.Toast.new(_("Downloading covers…")))
|
||||
|
||||
def update_cover_callback(manager: SgdbManager) -> None:
|
||||
nonlocal counter
|
||||
nonlocal games_len
|
||||
nonlocal download_toast
|
||||
|
||||
counter += 1
|
||||
if counter != games_len:
|
||||
return
|
||||
|
||||
for error in manager.collect_errors():
|
||||
if isinstance(error, FriendlyError):
|
||||
create_dialog(self, error.title, error.subtitle)
|
||||
break
|
||||
|
||||
for game in shared.store:
|
||||
game.update()
|
||||
|
||||
toast = Adw.Toast.new(_("Covers updated"))
|
||||
toast.set_priority(Adw.ToastPriority.HIGH)
|
||||
download_toast.dismiss()
|
||||
self.add_toast(toast)
|
||||
|
||||
for game in shared.store:
|
||||
sgdb_manager.process_game(game, {}, update_cover_callback)
|
||||
|
||||
self.sgdb_fetch_button.connect("clicked", update_sgdb)
|
||||
|
||||
# Switches
|
||||
self.bind_switches(
|
||||
{
|
||||
"exit-after-launch",
|
||||
"cover-launches-game",
|
||||
"high-quality-images",
|
||||
"remove-missing",
|
||||
"lutris-import-steam",
|
||||
"lutris-import-flatpak",
|
||||
"heroic-import-epic",
|
||||
"heroic-import-gog",
|
||||
"heroic-import-amazon",
|
||||
"heroic-import-sideload",
|
||||
"flatpak-import-launchers",
|
||||
"sgdb",
|
||||
"sgdb-prefer",
|
||||
"sgdb-animated",
|
||||
"desktop",
|
||||
}
|
||||
)
|
||||
|
||||
def set_sgdb_sensitive(widget: Adw.EntryRow) -> None:
|
||||
if not widget.get_text():
|
||||
shared.schema.set_boolean("sgdb", False)
|
||||
|
||||
self.sgdb_switch.set_sensitive(widget.get_text())
|
||||
|
||||
self.sgdb_key_entry_row.connect("changed", set_sgdb_sensitive)
|
||||
set_sgdb_sensitive(self.sgdb_key_entry_row)
|
||||
|
||||
def get_switch(self, setting: str) -> Any:
|
||||
return getattr(self, f'{setting.replace("-", "_")}_switch')
|
||||
|
||||
def bind_switches(self, settings: set[str]) -> None:
|
||||
for setting in settings:
|
||||
shared.schema.bind(
|
||||
setting,
|
||||
self.get_switch(setting),
|
||||
"active",
|
||||
Gio.SettingsBindFlags.DEFAULT,
|
||||
)
|
||||
|
||||
def choose_folder(
|
||||
self, _widget: Any, callback: Callable, callback_data: Optional[str] = None
|
||||
) -> None:
|
||||
self.file_chooser.select_folder(shared.win, None, callback, callback_data)
|
||||
|
||||
def undo_remove_all(self, *_args: Any) -> None:
|
||||
shared.win.get_application().state = shared.AppState.UNDO_REMOVE_ALL_GAMES
|
||||
for game in self.removed_games:
|
||||
game.removed = False
|
||||
game.save()
|
||||
game.update()
|
||||
|
||||
self.removed_games = set()
|
||||
self.toast.dismiss()
|
||||
shared.win.get_application().state = shared.AppState.DEFAULT
|
||||
shared.win.create_source_rows()
|
||||
|
||||
def remove_all_games(self, *_args: Any) -> None:
|
||||
shared.win.get_application().state = shared.AppState.REMOVE_ALL_GAMES
|
||||
shared.win.row_selected(None, shared.win.all_games_row_box.get_parent())
|
||||
for game in shared.store:
|
||||
if not game.removed:
|
||||
self.removed_games.add(game)
|
||||
game.removed = True
|
||||
game.save()
|
||||
game.update()
|
||||
|
||||
if shared.win.navigation_view.get_visible_page() == shared.win.details_page:
|
||||
shared.win.navigation_view.pop()
|
||||
|
||||
self.add_toast(self.toast)
|
||||
shared.win.get_application().state = shared.AppState.DEFAULT
|
||||
shared.win.create_source_rows()
|
||||
|
||||
def reset_app(self, *_args: Any) -> None:
|
||||
rmtree(shared.data_dir / "cartridges", True)
|
||||
rmtree(shared.config_dir / "cartridges", True)
|
||||
rmtree(shared.cache_dir / "cartridges", True)
|
||||
|
||||
for key in (
|
||||
(settings_schema_source := Gio.SettingsSchemaSource.get_default())
|
||||
.lookup(shared.APP_ID, True)
|
||||
.list_keys()
|
||||
):
|
||||
shared.schema.reset(key)
|
||||
for key in settings_schema_source.lookup(
|
||||
shared.APP_ID + ".State", True
|
||||
).list_keys():
|
||||
shared.state_schema.reset(key)
|
||||
|
||||
shared.win.get_application().quit()
|
||||
|
||||
def update_source_action_row_paths(self, source: Source) -> None:
|
||||
"""Set the dir subtitle for a source's action rows"""
|
||||
for location_name, location in source.locations._asdict().items():
|
||||
# Get the action row to subtitle
|
||||
action_row = getattr(
|
||||
self, f"{source.source_id}_{location_name}_action_row", None
|
||||
)
|
||||
if not action_row:
|
||||
continue
|
||||
path = Path(shared.schema.get_string(location.schema_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:
|
||||
"""Resolve locations and add a warning if location cannot be found"""
|
||||
|
||||
for location_name, location in source.locations._asdict().items():
|
||||
action_row = getattr(
|
||||
self, f"{source.source_id}_{location_name}_action_row", None
|
||||
)
|
||||
if not action_row:
|
||||
continue
|
||||
|
||||
try:
|
||||
location.resolve()
|
||||
|
||||
except UnresolvableLocationError:
|
||||
title = _("Installation Not Found")
|
||||
description = _("Select a valid directory.")
|
||||
format_start = '<span rise="12pt"><b><big>'
|
||||
format_end = "</big></b></span>\n"
|
||||
|
||||
popover = Gtk.Popover(
|
||||
focusable=True,
|
||||
child=(
|
||||
Gtk.Label(
|
||||
label=format_start + title + format_end + description,
|
||||
use_markup=True,
|
||||
wrap=True,
|
||||
max_width_chars=50,
|
||||
halign=Gtk.Align.CENTER,
|
||||
valign=Gtk.Align.CENTER,
|
||||
justify=Gtk.Justification.CENTER,
|
||||
margin_top=9,
|
||||
margin_bottom=9,
|
||||
margin_start=12,
|
||||
margin_end=12,
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
popover.update_property(
|
||||
(Gtk.AccessibleProperty.LABEL,), (title + description,)
|
||||
)
|
||||
|
||||
def set_a11y_label(widget: Gtk.Popover) -> None:
|
||||
self.set_focus(widget)
|
||||
|
||||
popover.connect("show", set_a11y_label)
|
||||
|
||||
menu_button = Gtk.MenuButton(
|
||||
icon_name="dialog-warning-symbolic",
|
||||
valign=Gtk.Align.CENTER,
|
||||
popover=popover,
|
||||
tooltip_text=_("Warning"),
|
||||
)
|
||||
menu_button.add_css_class("warning")
|
||||
|
||||
action_row.add_prefix(menu_button)
|
||||
self.warning_menu_buttons[source.source_id] = menu_button
|
||||
|
||||
def init_source_row(self, source: Source) -> None:
|
||||
"""Initialize a preference row for a source class"""
|
||||
|
||||
def set_dir(_widget: Any, result: Gio.Task, location_name: str) -> None:
|
||||
"""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]
|
||||
if location.check_candidate(path):
|
||||
shared.schema.set_string(location.schema_key, str(path))
|
||||
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]
|
||||
)
|
||||
self.warning_menu_buttons.pop(source.source_id)
|
||||
logging.debug("User-set value for %s is %s", location.schema_key, path)
|
||||
|
||||
# Bad picked location, inform user
|
||||
else:
|
||||
title = _("Invalid Directory")
|
||||
dialog = create_dialog(
|
||||
self,
|
||||
title,
|
||||
location.invalid_subtitle.format(source.name),
|
||||
"choose_folder",
|
||||
_("Set Location"),
|
||||
)
|
||||
|
||||
def on_response(widget: Any, response: str) -> None:
|
||||
if response == "choose_folder":
|
||||
self.choose_folder(widget, set_dir, location_name)
|
||||
|
||||
dialog.connect("response", on_response)
|
||||
|
||||
# Bind expander row activation to source being enabled
|
||||
expander_row = getattr(self, f"{source.source_id}_expander_row")
|
||||
shared.schema.bind(
|
||||
source.source_id,
|
||||
expander_row,
|
||||
"enable-expansion",
|
||||
Gio.SettingsBindFlags.DEFAULT,
|
||||
)
|
||||
|
||||
# Connect dir picker buttons
|
||||
for location_name in source.locations._asdict():
|
||||
button = getattr(
|
||||
self, f"{source.source_id}_{location_name}_file_chooser_button", None
|
||||
)
|
||||
if button is not None:
|
||||
button.connect("clicked", self.choose_folder, set_dir, location_name)
|
||||
|
||||
# Set the source row subtitles
|
||||
self.resolve_locations(source)
|
||||
self.update_source_action_row_paths(source)
|
||||
74
cartridges/shared.py.in
Normal file
74
cartridges/shared.py.in
Normal file
@@ -0,0 +1,74 @@
|
||||
# shared.py.in
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import os
|
||||
from enum import IntEnum, auto
|
||||
from pathlib import Path
|
||||
|
||||
from gi.repository import Gdk, Gio, GLib
|
||||
|
||||
|
||||
class AppState(IntEnum):
|
||||
DEFAULT = auto()
|
||||
LOAD_FROM_DISK = auto()
|
||||
IMPORT = auto()
|
||||
REMOVE_ALL_GAMES = auto()
|
||||
UNDO_REMOVE_ALL_GAMES = auto()
|
||||
|
||||
|
||||
APP_ID = "@APP_ID@"
|
||||
VERSION = "@VERSION@"
|
||||
PREFIX = "@PREFIX@"
|
||||
PROFILE = "@PROFILE@"
|
||||
SPEC_VERSION = 1.5 # The version of the game_id.json spec
|
||||
|
||||
schema = Gio.Settings.new(APP_ID)
|
||||
state_schema = Gio.Settings.new(APP_ID + ".State")
|
||||
|
||||
home = Path.home()
|
||||
data_dir = Path(GLib.get_user_data_dir())
|
||||
config_dir = Path(GLib.get_user_config_dir())
|
||||
cache_dir = Path(GLib.get_user_cache_dir())
|
||||
flatpak_dir = home / ".var" / "app"
|
||||
|
||||
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)")
|
||||
|
||||
try:
|
||||
scale_factor = max(
|
||||
monitor.get_scale_factor()
|
||||
for monitor in Gdk.Display.get_default().get_monitors()
|
||||
)
|
||||
except AttributeError: # If shared.py is imported by the search provider
|
||||
pass
|
||||
else:
|
||||
image_size = (200 * scale_factor, 300 * scale_factor)
|
||||
|
||||
# pylint: disable=invalid-name
|
||||
win = None
|
||||
importer = None
|
||||
import_time = None
|
||||
store = None
|
||||
log_files = None
|
||||
62
cartridges/store/managers/async_manager.py
Normal file
62
cartridges/store/managers/async_manager.py
Normal file
@@ -0,0 +1,62 @@
|
||||
# async_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from gi.repository import Gio
|
||||
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.manager import Manager
|
||||
|
||||
|
||||
class AsyncManager(Manager):
|
||||
"""Manager that can run asynchronously"""
|
||||
|
||||
blocking = False
|
||||
cancellable: Gio.Cancellable = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.cancellable = Gio.Cancellable()
|
||||
|
||||
def cancel_tasks(self):
|
||||
"""Cancel all tasks for this manager"""
|
||||
self.cancellable.cancel()
|
||||
|
||||
def reset_cancellable(self):
|
||||
"""Reset the cancellable for this manager.
|
||||
Already scheduled Tasks will no longer be cancellable."""
|
||||
self.cancellable = Gio.Cancellable()
|
||||
|
||||
def process_game(
|
||||
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
|
||||
) -> None:
|
||||
"""Create a task to process the game in a separate thread"""
|
||||
task = Gio.Task.new(None, self.cancellable, self._task_callback, (callback,))
|
||||
task.run_in_thread(lambda *_: self._task_thread_func((game, additional_data)))
|
||||
|
||||
def _task_thread_func(self, data):
|
||||
"""Task thread entry point"""
|
||||
game, additional_data, *_rest = data
|
||||
self.run(game, additional_data)
|
||||
|
||||
def _task_callback(self, _source_object, _result, data):
|
||||
"""Method run after the task is done"""
|
||||
callback, *_rest = data
|
||||
callback(self)
|
||||
198
cartridges/store/managers/cover_manager.py
Normal file
198
cartridges/store/managers/cover_manager.py
Normal file
@@ -0,0 +1,198 @@
|
||||
# local_cover_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
# Copyright 2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import requests
|
||||
from gi.repository import GdkPixbuf, Gio
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.manager import Manager
|
||||
from cartridges.store.managers.steam_api_manager import SteamAPIManager
|
||||
from cartridges.utils.save_cover import convert_cover, save_cover
|
||||
|
||||
|
||||
class ImageSize(NamedTuple):
|
||||
width: float = 0
|
||||
height: float = 0
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
return self.width / self.height
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.width}x{self.height}"
|
||||
|
||||
def __mul__(self, scale: float | int) -> "ImageSize":
|
||||
return ImageSize(
|
||||
self.width * scale,
|
||||
self.height * scale,
|
||||
)
|
||||
|
||||
def __truediv__(self, divisor: float | int) -> "ImageSize":
|
||||
return self * (1 / divisor)
|
||||
|
||||
def __add__(self, other_size: "ImageSize") -> "ImageSize":
|
||||
return ImageSize(
|
||||
self.width + other_size.width,
|
||||
self.height + other_size.height,
|
||||
)
|
||||
|
||||
def __sub__(self, other_size: "ImageSize") -> "ImageSize":
|
||||
return self + (other_size * -1)
|
||||
|
||||
def element_wise_div(self, other_size: "ImageSize") -> "ImageSize":
|
||||
"""Divide every element of self by the equivalent in the other size"""
|
||||
return ImageSize(
|
||||
self.width / other_size.width,
|
||||
self.height / other_size.height,
|
||||
)
|
||||
|
||||
def element_wise_mul(self, other_size: "ImageSize") -> "ImageSize":
|
||||
"""Multiply every element of self by the equivalent in the other size"""
|
||||
return ImageSize(
|
||||
self.width * other_size.width,
|
||||
self.height * other_size.height,
|
||||
)
|
||||
|
||||
def invert(self) -> "ImageSize":
|
||||
"""Invert the element of self"""
|
||||
return ImageSize(1, 1).element_wise_div(self)
|
||||
|
||||
|
||||
class CoverManager(Manager):
|
||||
"""
|
||||
Manager in charge of adding the cover image of the game
|
||||
|
||||
Order of priority is:
|
||||
1. local cover
|
||||
2. icon cover
|
||||
3. online cover
|
||||
"""
|
||||
|
||||
run_after = (SteamAPIManager,)
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
||||
|
||||
def download_image(self, url: str) -> Path:
|
||||
image_file = Gio.File.new_tmp()[0]
|
||||
path = Path(image_file.get_path())
|
||||
with requests.get(url, timeout=5) as cover:
|
||||
cover.raise_for_status()
|
||||
path.write_bytes(cover.content)
|
||||
return path
|
||||
|
||||
def is_stretchable(self, source_size: ImageSize, cover_size: ImageSize) -> bool:
|
||||
is_taller = source_size.aspect_ratio < cover_size.aspect_ratio
|
||||
if is_taller:
|
||||
return True
|
||||
max_stretch = 0.12
|
||||
resized_height = (1 / source_size.aspect_ratio) * cover_size.width
|
||||
stretch = 1 - (resized_height / cover_size.height)
|
||||
return stretch <= max_stretch
|
||||
|
||||
def composite_cover(
|
||||
self,
|
||||
image_path: Path,
|
||||
scale: float = 1,
|
||||
blur_size: ImageSize = ImageSize(2, 2),
|
||||
) -> GdkPixbuf.Pixbuf:
|
||||
"""
|
||||
Return the image composited with a background blur.
|
||||
If the image is stretchable, just stretch it.
|
||||
|
||||
:param path: Path where the source image is located
|
||||
:param scale:
|
||||
Scale of the smalled image side
|
||||
compared to the corresponding side in the cover
|
||||
:param blur_size: Size of the downscaled image used for the blur
|
||||
"""
|
||||
|
||||
# Load source image
|
||||
source = GdkPixbuf.Pixbuf.new_from_file(
|
||||
str(convert_cover(image_path, resize=False))
|
||||
)
|
||||
source_size = ImageSize(source.get_width(), source.get_height())
|
||||
cover_size = ImageSize._make(shared.image_size)
|
||||
|
||||
# Stretch if possible
|
||||
if scale == 1 and self.is_stretchable(source_size, cover_size):
|
||||
return source
|
||||
|
||||
# Create the blurred cover background
|
||||
# fmt: off
|
||||
cover = (
|
||||
source
|
||||
.scale_simple(*blur_size, GdkPixbuf.InterpType.BILINEAR)
|
||||
.scale_simple(*cover_size, GdkPixbuf.InterpType.BILINEAR)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
# Scale to fit, apply scaling, then center
|
||||
uniform_scale = scale * min(cover_size.element_wise_div(source_size))
|
||||
source_in_cover_size = source_size * uniform_scale
|
||||
source_in_cover_position = (cover_size - source_in_cover_size) / 2
|
||||
|
||||
# Center the scaled source image in the cover
|
||||
source.composite(
|
||||
cover,
|
||||
*source_in_cover_position,
|
||||
*source_in_cover_size,
|
||||
*source_in_cover_position,
|
||||
uniform_scale,
|
||||
uniform_scale,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255,
|
||||
)
|
||||
return cover
|
||||
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
if game.blacklisted:
|
||||
return
|
||||
for key in (
|
||||
"local_image_path",
|
||||
"local_icon_path",
|
||||
"online_cover_url",
|
||||
):
|
||||
# Get an image path
|
||||
if not (value := additional_data.get(key)):
|
||||
continue
|
||||
if key == "online_cover_url":
|
||||
image_path = self.download_image(value)
|
||||
else:
|
||||
image_path = Path(value)
|
||||
if not image_path.is_file():
|
||||
continue
|
||||
|
||||
# Icon cover
|
||||
composite_kwargs = {}
|
||||
|
||||
if key == "local_icon_path":
|
||||
composite_kwargs["scale"] = 0.7
|
||||
composite_kwargs["blur_size"] = ImageSize(1, 2)
|
||||
|
||||
save_cover(
|
||||
game.game_id,
|
||||
convert_cover(
|
||||
pixbuf=self.composite_cover(image_path, **composite_kwargs)
|
||||
),
|
||||
)
|
||||
76
cartridges/store/managers/display_manager.py
Normal file
76
cartridges/store/managers/display_manager.py
Normal file
@@ -0,0 +1,76 @@
|
||||
# display_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.game_cover import GameCover
|
||||
from cartridges.store.managers.manager import Manager
|
||||
from cartridges.store.managers.sgdb_manager import SgdbManager
|
||||
from cartridges.store.managers.steam_api_manager import SteamAPIManager
|
||||
|
||||
|
||||
class DisplayManager(Manager):
|
||||
"""Manager in charge of adding a game to the UI"""
|
||||
|
||||
run_after = (SteamAPIManager, SgdbManager)
|
||||
signals = {"update-ready"}
|
||||
|
||||
def main(self, game: Game, _additional_data: dict) -> None:
|
||||
if game.get_parent():
|
||||
game.get_parent().get_parent().remove(game)
|
||||
if game.get_parent():
|
||||
game.get_parent().set_child()
|
||||
|
||||
game.menu_button.set_menu_model(
|
||||
game.hidden_game_options if game.hidden else game.game_options
|
||||
)
|
||||
|
||||
game.title.set_label(game.name)
|
||||
|
||||
game.menu_button.get_popover().connect(
|
||||
"notify::visible", game.toggle_play, None
|
||||
)
|
||||
game.menu_button.get_popover().connect(
|
||||
"notify::visible", shared.win.set_active_game, game
|
||||
)
|
||||
|
||||
if game.game_id in shared.win.game_covers:
|
||||
game.game_cover = shared.win.game_covers[game.game_id]
|
||||
game.game_cover.add_picture(game.cover)
|
||||
else:
|
||||
game.game_cover = GameCover({game.cover}, game.get_cover_path())
|
||||
shared.win.game_covers[game.game_id] = game.game_cover
|
||||
|
||||
if (
|
||||
shared.win.navigation_view.get_visible_page() == shared.win.details_page
|
||||
and shared.win.active_game == game
|
||||
):
|
||||
shared.win.show_details_page(game)
|
||||
|
||||
if not game.removed and not game.blacklisted:
|
||||
if game.hidden:
|
||||
shared.win.hidden_library.append(game)
|
||||
else:
|
||||
shared.win.library.append(game)
|
||||
game.get_parent().set_focusable(False)
|
||||
|
||||
shared.win.set_library_child()
|
||||
|
||||
if shared.win.get_application().state == shared.AppState.DEFAULT:
|
||||
shared.win.create_source_rows()
|
||||
59
cartridges/store/managers/file_manager.py
Normal file
59
cartridges/store/managers/file_manager.py
Normal file
@@ -0,0 +1,59 @@
|
||||
# file_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.async_manager import AsyncManager
|
||||
from cartridges.store.managers.steam_api_manager import SteamAPIManager
|
||||
|
||||
|
||||
class FileManager(AsyncManager):
|
||||
"""Manager in charge of saving a game to a file"""
|
||||
|
||||
run_after = (SteamAPIManager,)
|
||||
signals = {"save-ready"}
|
||||
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
if additional_data.get("skip_save"): # Skip saving when loading games from disk
|
||||
return
|
||||
|
||||
shared.games_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
attrs = (
|
||||
"added",
|
||||
"executable",
|
||||
"game_id",
|
||||
"source",
|
||||
"hidden",
|
||||
"last_played",
|
||||
"name",
|
||||
"developer",
|
||||
"removed",
|
||||
"blacklisted",
|
||||
"version",
|
||||
)
|
||||
|
||||
json.dump(
|
||||
{attr: getattr(game, attr) for attr in attrs if attr},
|
||||
(shared.games_dir / f"{game.game_id}.json").open("w"),
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
)
|
||||
120
cartridges/store/managers/manager.py
Normal file
120
cartridges/store/managers/manager.py
Normal file
@@ -0,0 +1,120 @@
|
||||
# manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from abc import abstractmethod
|
||||
from time import sleep
|
||||
from typing import Any, Callable, Container
|
||||
|
||||
from cartridges.errors.error_producer import ErrorProducer
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
|
||||
|
||||
class Manager(ErrorProducer):
|
||||
"""Class in charge of handling a post creation action for games.
|
||||
|
||||
* May connect to signals on the game to handle them.
|
||||
* May cancel its running tasks on critical error,
|
||||
in that case a new cancellable must be generated for new tasks to run.
|
||||
* May be retried on some specific error types
|
||||
"""
|
||||
|
||||
run_after: Container[type["Manager"]] = tuple()
|
||||
blocking: bool = True
|
||||
|
||||
retryable_on: Container[type[Exception]] = tuple()
|
||||
continue_on: Container[type[Exception]] = tuple()
|
||||
signals: Container[type[str]] = set()
|
||||
retry_delay: int = 3
|
||||
max_tries: int = 3
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return type(self).__name__
|
||||
|
||||
@abstractmethod
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
"""
|
||||
Manager specific logic triggered by the run method
|
||||
* Implemented by final child classes
|
||||
* May block its thread
|
||||
* May raise retryable exceptions that will trigger a retry if possible
|
||||
* May raise other exceptions that will be reported
|
||||
"""
|
||||
|
||||
def run(self, game: Game, additional_data: dict) -> None:
|
||||
"""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:
|
||||
nonlocal tries
|
||||
|
||||
# If FriendlyError, handle its cause instead
|
||||
base_error = error
|
||||
if isinstance(error, FriendlyError):
|
||||
error = error.__cause__
|
||||
|
||||
log_args = (
|
||||
type(error).__name__,
|
||||
self.name,
|
||||
f"{game.name} ({game.game_id})",
|
||||
)
|
||||
|
||||
out_of_retries_format = "Out of retries dues to %s in %s for %s"
|
||||
retrying_format = "Retrying %s in %s for %s"
|
||||
unretryable_format = "Unretryable %s in %s for %s"
|
||||
|
||||
if type(error) in self.continue_on:
|
||||
# Handle skippable errors (skip silently)
|
||||
return
|
||||
|
||||
if type(error) in self.retryable_on:
|
||||
if tries > self.max_tries:
|
||||
# Handle being out of retries
|
||||
logging.error(out_of_retries_format, *log_args)
|
||||
self.report_error(base_error)
|
||||
else:
|
||||
# Handle retryable errors
|
||||
logging.error(retrying_format, *log_args)
|
||||
sleep(self.retry_delay)
|
||||
tries += 1
|
||||
try_manager_logic()
|
||||
|
||||
else:
|
||||
# Handle unretryable errors
|
||||
logging.error(unretryable_format, *log_args, exc_info=error)
|
||||
self.report_error(base_error)
|
||||
|
||||
def try_manager_logic() -> None:
|
||||
try:
|
||||
self.main(game, additional_data)
|
||||
except Exception as error: # pylint: disable=broad-exception-caught
|
||||
handle_error(error)
|
||||
|
||||
try_manager_logic()
|
||||
|
||||
def process_game(
|
||||
self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
|
||||
) -> None:
|
||||
"""Pass the game through the manager"""
|
||||
self.run(game, additional_data)
|
||||
callback(self)
|
||||
48
cartridges/store/managers/sgdb_manager.py
Normal file
48
cartridges/store/managers/sgdb_manager.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# sgdb_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from json import JSONDecodeError
|
||||
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
|
||||
from cartridges.errors.friendly_error import FriendlyError
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.async_manager import AsyncManager
|
||||
from cartridges.store.managers.cover_manager import CoverManager
|
||||
from cartridges.store.managers.steam_api_manager import SteamAPIManager
|
||||
from cartridges.utils.steamgriddb import SgdbAuthError, SgdbHelper
|
||||
|
||||
|
||||
class SgdbManager(AsyncManager):
|
||||
"""Manager in charge of downloading a game's cover from SteamGridDB"""
|
||||
|
||||
run_after = (SteamAPIManager, CoverManager)
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
|
||||
|
||||
def main(self, game: Game, _additional_data: dict) -> None:
|
||||
try:
|
||||
sgdb = SgdbHelper()
|
||||
sgdb.conditionaly_update_cover(game)
|
||||
except SgdbAuthError as error:
|
||||
# If invalid auth, cancel all SGDBManager tasks
|
||||
self.cancellable.cancel()
|
||||
raise FriendlyError(
|
||||
_("Couldn't Authenticate SteamGridDB"),
|
||||
_("Verify your API key in preferences"),
|
||||
) from error
|
||||
57
cartridges/store/managers/steam_api_manager.py
Normal file
57
cartridges/store/managers/steam_api_manager.py
Normal file
@@ -0,0 +1,57 @@
|
||||
# steam_api_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
from urllib3.exceptions import ConnectionError as Urllib3ConnectionError
|
||||
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.async_manager import AsyncManager
|
||||
from cartridges.utils.steam import (
|
||||
SteamAPIHelper,
|
||||
SteamGameNotFoundError,
|
||||
SteamNotAGameError,
|
||||
SteamRateLimiter,
|
||||
)
|
||||
|
||||
|
||||
class SteamAPIManager(AsyncManager):
|
||||
"""Manager in charge of completing a game's data from the Steam API"""
|
||||
|
||||
retryable_on = (HTTPError, SSLError, Urllib3ConnectionError)
|
||||
|
||||
steam_api_helper: SteamAPIHelper = None
|
||||
steam_rate_limiter: SteamRateLimiter = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.steam_rate_limiter = SteamRateLimiter()
|
||||
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
|
||||
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
# Skip non-Steam games
|
||||
appid = additional_data.get("steam_appid", None)
|
||||
if appid is None:
|
||||
return
|
||||
# Get online metadata
|
||||
try:
|
||||
online_data = self.steam_api_helper.get_api_data(appid=appid)
|
||||
except (SteamNotAGameError, SteamGameNotFoundError):
|
||||
game.update_values({"blacklisted": True})
|
||||
else:
|
||||
game.update_values(online_data)
|
||||
110
cartridges/store/pipeline.py
Normal file
110
cartridges/store/pipeline.py
Normal file
@@ -0,0 +1,110 @@
|
||||
# pipeline.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from typing import Iterable
|
||||
|
||||
from gi.repository import GObject
|
||||
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.manager import Manager
|
||||
|
||||
|
||||
class Pipeline(GObject.Object):
|
||||
"""Class representing a set of managers for a game"""
|
||||
|
||||
game: Game
|
||||
additional_data: dict
|
||||
|
||||
waiting: set[Manager]
|
||||
running: set[Manager]
|
||||
done: set[Manager]
|
||||
|
||||
def __init__(
|
||||
self, game: Game, additional_data: dict, managers: Iterable[Manager]
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self.game = game
|
||||
self.additional_data = additional_data
|
||||
self.waiting = set(managers)
|
||||
self.running = set()
|
||||
self.done = set()
|
||||
|
||||
@property
|
||||
def not_done(self) -> set[Manager]:
|
||||
"""Get the managers that are not done yet"""
|
||||
return self.waiting | self.running
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return len(self.waiting) == 0 and len(self.running) == 0
|
||||
|
||||
@property
|
||||
def blocked(self) -> set[Manager]:
|
||||
"""Get the managers that cannot run because their dependencies aren't done"""
|
||||
blocked = set()
|
||||
for waiting in self.waiting:
|
||||
for not_done in self.not_done:
|
||||
if waiting == not_done:
|
||||
continue
|
||||
if type(not_done) in waiting.run_after:
|
||||
blocked.add(waiting)
|
||||
return blocked
|
||||
|
||||
@property
|
||||
def ready(self) -> set[Manager]:
|
||||
"""Get the managers that can be run"""
|
||||
return self.waiting - self.blocked
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
"""Get the pipeline progress. Should only be a rough idea."""
|
||||
n_done = len(self.done)
|
||||
n_total = len(self.waiting) + len(self.running) + n_done
|
||||
try:
|
||||
progress = n_done / n_total
|
||||
except ZeroDivisionError:
|
||||
progress = 1
|
||||
return progress
|
||||
|
||||
def advance(self) -> None:
|
||||
"""Spawn tasks for managers that are able to run for a game"""
|
||||
|
||||
# Separate blocking / async managers
|
||||
managers = self.ready
|
||||
blocking = set(filter(lambda manager: manager.blocking, managers))
|
||||
parallel = managers - blocking
|
||||
|
||||
# Schedule parallel managers, then run the blocking ones
|
||||
for manager in (*parallel, *blocking):
|
||||
self.waiting.remove(manager)
|
||||
self.running.add(manager)
|
||||
manager.process_game(self.game, self.additional_data, self.manager_callback)
|
||||
|
||||
def manager_callback(self, manager: Manager) -> None:
|
||||
"""Method called by a manager when it's done"""
|
||||
logging.debug("%s done for %s", manager.name, self.game.game_id)
|
||||
self.running.remove(manager)
|
||||
self.done.add(manager)
|
||||
self.emit("advanced")
|
||||
self.advance()
|
||||
|
||||
@GObject.Signal(name="advanced")
|
||||
def advanced(self): # type: ignore
|
||||
"""Signal emitted when the pipeline has advanced"""
|
||||
163
cartridges/store/store.py
Normal file
163
cartridges/store/store.py
Normal file
@@ -0,0 +1,163 @@
|
||||
# store.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from typing import Any, Generator, MutableMapping, Optional
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.store.managers.manager import Manager
|
||||
from cartridges.store.pipeline import Pipeline
|
||||
|
||||
|
||||
class Store:
|
||||
"""Class in charge of handling games being added to the app."""
|
||||
|
||||
managers: dict[type[Manager], Manager]
|
||||
pipeline_managers: set[Manager]
|
||||
pipelines: dict[str, Pipeline]
|
||||
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"""
|
||||
if not isinstance(obj, Game):
|
||||
return False
|
||||
if not (source_mapping := self.source_games.get(obj.base_source)):
|
||||
return False
|
||||
return obj.game_id in source_mapping
|
||||
|
||||
def __iter__(self) -> Generator[Game, None, None]:
|
||||
"""Iterate through the games in the store with `for ... in`"""
|
||||
for _source_id, games_mapping in self.source_games.items():
|
||||
for _game_id, game in games_mapping.items():
|
||||
yield game
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Get the number of games in the store with the `len` builtin"""
|
||||
return sum(len(source_mapping) for source_mapping in self.source_games.values())
|
||||
|
||||
def __getitem__(self, game_id: str) -> Game:
|
||||
"""Get a game by its id with `store["game_id_goes_here"]`"""
|
||||
for game in iter(self):
|
||||
if game.game_id == game_id:
|
||||
return game
|
||||
raise KeyError("Game not found in store")
|
||||
|
||||
def get(self, game_id: str, default: Any = None) -> Game | Any:
|
||||
"""Get a game by its ID, with a fallback if not found"""
|
||||
try:
|
||||
game = self[game_id]
|
||||
return game
|
||||
except KeyError:
|
||||
return default
|
||||
|
||||
def add_manager(self, manager: Manager, in_pipeline: bool = True) -> None:
|
||||
"""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:
|
||||
"""Change if a manager should run in new pipelines"""
|
||||
if enable:
|
||||
self.pipeline_managers.add(self.managers[manager_type])
|
||||
else:
|
||||
self.pipeline_managers.discard(self.managers[manager_type])
|
||||
|
||||
def cleanup_game(self, game: Game) -> None:
|
||||
"""Remove a game's files, dismiss any loose toasts"""
|
||||
for path in (
|
||||
shared.games_dir / f"{game.game_id}.json",
|
||||
shared.covers_dir / f"{game.game_id}.tiff",
|
||||
shared.covers_dir / f"{game.game_id}.gif",
|
||||
):
|
||||
path.unlink(missing_ok=True)
|
||||
|
||||
# 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]:
|
||||
"""Add a game to the app"""
|
||||
|
||||
# Ignore games from a newer spec version
|
||||
if game.version > shared.SPEC_VERSION:
|
||||
return None
|
||||
|
||||
# Scanned game is already removed, just clean it up
|
||||
if game.removed:
|
||||
self.cleanup_game(game)
|
||||
return None
|
||||
|
||||
# Handle game duplicates
|
||||
stored_game = self.get(game.game_id)
|
||||
if not stored_game:
|
||||
# New game, do as normal
|
||||
logging.debug("New store game %s (%s)", game.name, game.game_id)
|
||||
self.new_game_ids.add(game.game_id)
|
||||
elif stored_game.removed:
|
||||
# Will replace a removed game, cleanup its remains
|
||||
logging.debug(
|
||||
"New store game %s (%s) (replacing a removed one)",
|
||||
game.name,
|
||||
game.game_id,
|
||||
)
|
||||
self.cleanup_game(stored_game)
|
||||
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
|
||||
for manager in self.managers.values():
|
||||
for signal in manager.signals:
|
||||
game.connect(signal, manager.run)
|
||||
|
||||
# Add the game to the store
|
||||
if not game.base_source in self.source_games:
|
||||
self.source_games[game.base_source] = {}
|
||||
self.source_games[game.base_source][game.game_id] = game
|
||||
|
||||
# Run the pipeline for the game
|
||||
if not run_pipeline:
|
||||
return None
|
||||
pipeline = Pipeline(game, additional_data, self.pipeline_managers)
|
||||
self.pipelines[game.game_id] = pipeline
|
||||
pipeline.advance()
|
||||
return pipeline
|
||||
39
cartridges/utils/create_dialog.py
Normal file
39
cartridges/utils/create_dialog.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# create_dialog.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 typing import Optional
|
||||
|
||||
from gi.repository import Adw, Gtk
|
||||
|
||||
|
||||
def create_dialog(
|
||||
win: Gtk.Window,
|
||||
heading: str,
|
||||
body: str,
|
||||
extra_option: Optional[str] = None,
|
||||
extra_label: Optional[str] = None,
|
||||
) -> Adw.MessageDialog:
|
||||
dialog = Adw.MessageDialog.new(win, heading, body)
|
||||
dialog.add_response("dismiss", _("Dismiss"))
|
||||
|
||||
if extra_option:
|
||||
dialog.add_response(extra_option, _(extra_label))
|
||||
|
||||
dialog.present()
|
||||
return dialog
|
||||
128
cartridges/utils/migrate_files_v1_to_v2.py
Normal file
128
cartridges/utils/migrate_files_v1_to_v2.py
Normal file
@@ -0,0 +1,128 @@
|
||||
# migrate_files_v1_to_v2.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
old_data_dir = shared.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:
|
||||
"""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
|
||||
if not cover_path.is_file():
|
||||
continue
|
||||
destination_cover_path = shared.covers_dir / cover_path.name
|
||||
logging.info("Moving %s -> %s", str(cover_path), str(destination_cover_path))
|
||||
cover_path.rename(destination_cover_path)
|
||||
|
||||
|
||||
def migrate_files_v1_to_v2() -> None:
|
||||
"""
|
||||
Migrate user data from the v1.X locations to the latest location.
|
||||
|
||||
Fix for commit 4a204442b5d8ba2e918f8c2605d72e483bf35efd
|
||||
where the windows directories for data, config and cache changed.
|
||||
"""
|
||||
|
||||
# Skip if there is no old dir
|
||||
# Skip if old == current
|
||||
# Skip if already migrated
|
||||
if (
|
||||
not old_data_dir.is_dir()
|
||||
or str(old_data_dir) == str(shared.data_dir)
|
||||
or migrated_file_path.is_file()
|
||||
):
|
||||
return
|
||||
|
||||
logging.info("Migrating data dir %s", str(old_data_dir))
|
||||
|
||||
# Create new directories
|
||||
shared.games_dir.mkdir(parents=True, exist_ok=True)
|
||||
shared.covers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
old_game_paths = set(old_games_dir.glob("*.json"))
|
||||
old_imported_game_paths = set(
|
||||
filter(lambda path: path.name.startswith("imported_"), old_game_paths)
|
||||
)
|
||||
old_other_game_paths = old_game_paths - old_imported_game_paths
|
||||
|
||||
# Discover current imported games
|
||||
imported_game_number = 0
|
||||
imported_execs = set()
|
||||
for game_path in shared.games_dir.glob("imported_*.json"):
|
||||
try:
|
||||
game_data = json.load(game_path.open("r"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
number = int(game_data["game_id"].replace("imported_", ""))
|
||||
imported_game_number = max(number, imported_game_number)
|
||||
imported_execs.add(game_data["executable"])
|
||||
|
||||
# Migrate imported game files
|
||||
for game_path in old_imported_game_paths:
|
||||
try:
|
||||
game_data = json.load(game_path.open("r"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
|
||||
# Don't migrate if there's a game with the same exec
|
||||
if game_data["executable"] in imported_execs:
|
||||
continue
|
||||
|
||||
# Migrate with updated index
|
||||
imported_game_number += 1
|
||||
game_id = f"imported_{imported_game_number}"
|
||||
game_data["game_id"] = game_id
|
||||
destination_game_path = shared.games_dir / f"{game_id}.json"
|
||||
logging.info(
|
||||
"Moving (updated id) %s -> %s", str(game_path), str(destination_game_path)
|
||||
)
|
||||
json.dump(
|
||||
game_data,
|
||||
destination_game_path.open("w"),
|
||||
indent=4,
|
||||
sort_keys=True,
|
||||
)
|
||||
game_path.unlink()
|
||||
migrate_game_covers(game_path)
|
||||
|
||||
# Migrate all other games
|
||||
for game_path in old_other_game_paths:
|
||||
# Do nothing if already in games dir
|
||||
destination_game_path = shared.games_dir / game_path.name
|
||||
if destination_game_path.exists():
|
||||
continue
|
||||
|
||||
# Else, migrate the game
|
||||
logging.info("Moving %s -> %s", str(game_path), str(destination_game_path))
|
||||
game_path.rename(destination_game_path)
|
||||
migrate_game_covers(game_path)
|
||||
|
||||
# Signal that this dir is migrated
|
||||
migrated_file_path.touch()
|
||||
logging.info("Migration done")
|
||||
221
cartridges/utils/rate_limiter.py
Normal file
221
cartridges/utils/rate_limiter.py
Normal file
@@ -0,0 +1,221 @@
|
||||
# rate_limiter.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from 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):
|
||||
"""Utility class used for rate limiters, counting how many picks
|
||||
happened in a given period"""
|
||||
|
||||
period: int
|
||||
|
||||
timestamps: list[float]
|
||||
timestamps_lock: Lock
|
||||
|
||||
def __init__(self, period: int) -> None:
|
||||
self.period = period
|
||||
self.timestamps = []
|
||||
self.timestamps_lock = Lock()
|
||||
|
||||
def remove_old_entries(self) -> None:
|
||||
"""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:
|
||||
"""Add timestamps to the history.
|
||||
If none given, will add the current timestamp"""
|
||||
if len(new_timestamps) == 0:
|
||||
new_timestamps = (time(),)
|
||||
with self.timestamps_lock:
|
||||
self.timestamps.extend(new_timestamps)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""How many entries were logged in the period"""
|
||||
self.remove_old_entries()
|
||||
with self.timestamps_lock:
|
||||
return len(self.timestamps)
|
||||
|
||||
@property
|
||||
def start(self) -> float:
|
||||
"""Get the time at which the history started"""
|
||||
self.remove_old_entries()
|
||||
with self.timestamps_lock:
|
||||
try:
|
||||
entry = self.timestamps[0]
|
||||
except IndexError:
|
||||
entry = time()
|
||||
return entry
|
||||
|
||||
def copy_timestamps(self) -> list[float]:
|
||||
"""Get a copy of the timestamps history"""
|
||||
self.remove_old_entries()
|
||||
with self.timestamps_lock:
|
||||
return self.timestamps.copy()
|
||||
|
||||
|
||||
# pylint: disable=too-many-instance-attributes
|
||||
class RateLimiter(AbstractContextManager):
|
||||
"""
|
||||
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
|
||||
"""
|
||||
|
||||
refill_period_seconds: int
|
||||
refill_period_tokens: int
|
||||
burst_tokens: int
|
||||
|
||||
pick_history: PickHistory
|
||||
bucket: BoundedSemaphore
|
||||
queue: deque[Lock]
|
||||
queue_lock: Lock
|
||||
|
||||
# Protect the number of tokens behind a lock
|
||||
__n_tokens_lock: Lock
|
||||
__n_tokens = 0
|
||||
|
||||
@property
|
||||
def n_tokens(self) -> int:
|
||||
with self.__n_tokens_lock:
|
||||
return self.__n_tokens
|
||||
|
||||
@n_tokens.setter
|
||||
def n_tokens(self, value: int) -> None:
|
||||
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:
|
||||
"""Initialize the limiter"""
|
||||
|
||||
self._init_pick_history()
|
||||
|
||||
# Create synchronization data
|
||||
self.__n_tokens_lock = Lock()
|
||||
self.queue_lock = Lock()
|
||||
self.queue = deque()
|
||||
|
||||
# Initialize the token bucket
|
||||
self.bucket = BoundedSemaphore(self.burst_tokens)
|
||||
self.n_tokens = self.burst_tokens
|
||||
|
||||
# Spawn daemon thread that refills the bucket
|
||||
refill_thread = Thread(target=self.refill_thread_func, daemon=True)
|
||||
refill_thread.start()
|
||||
|
||||
@property
|
||||
def refill_spacing(self) -> float:
|
||||
"""
|
||||
Get the current refill spacing.
|
||||
|
||||
Ensures that even with a burst in the period, the limit will not be exceeded.
|
||||
"""
|
||||
|
||||
# Compute ideal spacing
|
||||
tokens_left = self.refill_period_tokens - len(self.pick_history) # type: ignore
|
||||
seconds_left = self.pick_history.start + self.refill_period_seconds - time() # type: ignore
|
||||
try:
|
||||
spacing_seconds = seconds_left / tokens_left
|
||||
except ZeroDivisionError:
|
||||
# There were no remaining tokens, gotta wait until end of the period
|
||||
spacing_seconds = seconds_left
|
||||
|
||||
# Prevent spacing dropping down lower than the natural spacing
|
||||
natural_spacing = self.refill_period_seconds / self.refill_period_tokens
|
||||
return max(natural_spacing, spacing_seconds)
|
||||
|
||||
def refill(self) -> None:
|
||||
"""Add a token back in the bucket"""
|
||||
sleep(self.refill_spacing)
|
||||
try:
|
||||
self.bucket.release()
|
||||
except ValueError:
|
||||
# Bucket was full
|
||||
pass
|
||||
else:
|
||||
self.n_tokens += 1
|
||||
|
||||
def refill_thread_func(self) -> None:
|
||||
"""Entry point for the daemon thread that is refilling the bucket"""
|
||||
while True:
|
||||
self.refill()
|
||||
|
||||
def update_queue(self) -> None:
|
||||
"""Update the queue, moving it forward if possible. Non-blocking."""
|
||||
update_thread = Thread(target=self.queue_update_thread_func, daemon=True)
|
||||
update_thread.start()
|
||||
|
||||
def queue_update_thread_func(self) -> None:
|
||||
"""Queue-updating thread's entry point"""
|
||||
with self.queue_lock:
|
||||
if len(self.queue) == 0:
|
||||
return
|
||||
# Not using with because we don't want to release to the bucket
|
||||
self.bucket.acquire() # pylint: disable=consider-using-with
|
||||
self.n_tokens -= 1
|
||||
lock = self.queue.pop()
|
||||
lock.release()
|
||||
|
||||
def add_to_queue(self) -> Lock:
|
||||
"""Create a lock, add it to the queue and return it"""
|
||||
lock = Lock()
|
||||
# We want the lock locked until its turn in queue
|
||||
lock.acquire() # pylint: disable=consider-using-with
|
||||
with self.queue_lock:
|
||||
self.queue.appendleft(lock)
|
||||
return lock
|
||||
|
||||
def acquire(self) -> None:
|
||||
"""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
|
||||
|
||||
# --- Support for use in with statements
|
||||
|
||||
def __enter__(self) -> None:
|
||||
self.acquire()
|
||||
|
||||
def __exit__(self, *_args: Any) -> None:
|
||||
pass
|
||||
45
cartridges/utils/relative_date.py
Normal file
45
cartridges/utils/relative_date.py
Normal file
@@ -0,0 +1,45 @@
|
||||
# relative_date.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
def relative_date(timestamp: int) -> Any: # pylint: disable=too-many-return-statements
|
||||
days_no = ((today := datetime.today()) - datetime.fromtimestamp(timestamp)).days
|
||||
|
||||
if days_no == 0:
|
||||
return _("Today")
|
||||
if days_no == 1:
|
||||
return _("Yesterday")
|
||||
if days_no <= (day_of_week := today.weekday()):
|
||||
return GLib.DateTime.new_from_unix_utc(timestamp).format("%A")
|
||||
if days_no <= day_of_week + 7:
|
||||
return _("Last Week")
|
||||
if days_no <= (day_of_month := today.day):
|
||||
return _("This Month")
|
||||
if days_no <= day_of_month + 30:
|
||||
return _("Last Month")
|
||||
if days_no < (day_of_year := today.timetuple().tm_yday):
|
||||
return GLib.DateTime.new_from_unix_utc(timestamp).format("%B")
|
||||
if days_no <= day_of_year + 365:
|
||||
return _("Last Year")
|
||||
return GLib.DateTime.new_from_unix_utc(timestamp).format("%Y")
|
||||
24
cartridges/utils/run_executable.py
Normal file
24
cartridges/utils/run_executable.py
Normal file
@@ -0,0 +1,24 @@
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
from shlex import quote
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
|
||||
def run_executable(executable) -> None:
|
||||
args = (
|
||||
"flatpak-spawn --host /bin/sh -c " + quote(executable) # Flatpak
|
||||
if os.getenv("FLATPAK_ID") == shared.APP_ID
|
||||
else executable # Others
|
||||
)
|
||||
|
||||
logging.info("Launching `%s`", str(args))
|
||||
# pylint: disable=consider-using-with
|
||||
subprocess.Popen(
|
||||
args,
|
||||
cwd=shared.home,
|
||||
shell=True,
|
||||
start_new_session=True,
|
||||
creationflags=subprocess.CREATE_NEW_PROCESS_GROUP if os.name == "nt" else 0, # type: ignore
|
||||
)
|
||||
112
cartridges/utils/save_cover.py
Normal file
112
cartridges/utils/save_cover.py
Normal file
@@ -0,0 +1,112 @@
|
||||
# save_cover.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
from typing import Optional
|
||||
|
||||
from gi.repository import Gdk, GdkPixbuf, Gio, GLib
|
||||
from PIL import Image, ImageSequence, UnidentifiedImageError
|
||||
|
||||
from cartridges import shared
|
||||
|
||||
|
||||
def convert_cover(
|
||||
cover_path: Optional[Path] = None,
|
||||
pixbuf: Optional[GdkPixbuf.Pixbuf] = None,
|
||||
resize: bool = True,
|
||||
) -> Optional[Path]:
|
||||
if not cover_path and not pixbuf:
|
||||
return None
|
||||
|
||||
pixbuf_extensions = set()
|
||||
for pixbuf_format in GdkPixbuf.Pixbuf.get_formats():
|
||||
for pixbuf_extension in pixbuf_format.get_extensions():
|
||||
pixbuf_extensions.add(pixbuf_extension)
|
||||
|
||||
if not resize and cover_path and cover_path.suffix.lower()[1:] in pixbuf_extensions:
|
||||
return cover_path
|
||||
|
||||
if pixbuf:
|
||||
cover_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
|
||||
pixbuf.savev(str(cover_path), "tiff", ["compression"], ["1"])
|
||||
|
||||
try:
|
||||
with Image.open(cover_path) as image:
|
||||
if getattr(image, "is_animated", False):
|
||||
frames = tuple(
|
||||
frame.resize((200, 300)) if resize else frame
|
||||
for frame in ImageSequence.Iterator(image)
|
||||
)
|
||||
|
||||
tmp_path = Path(Gio.File.new_tmp("XXXXXX.gif")[0].get_path())
|
||||
frames[0].save(
|
||||
tmp_path,
|
||||
save_all=True,
|
||||
append_images=frames[1:],
|
||||
)
|
||||
|
||||
else:
|
||||
# This might not be necessary in the future
|
||||
# https://github.com/python-pillow/Pillow/issues/2663
|
||||
if image.mode not in ("RGB", "RGBA"):
|
||||
image = image.convert("RGBA")
|
||||
|
||||
tmp_path = Path(Gio.File.new_tmp("XXXXXX.tiff")[0].get_path())
|
||||
(image.resize(shared.image_size) if resize else image).save(
|
||||
tmp_path,
|
||||
compression="tiff_adobe_deflate"
|
||||
if shared.schema.get_boolean("high-quality-images")
|
||||
else "webp",
|
||||
)
|
||||
except UnidentifiedImageError:
|
||||
try:
|
||||
Gdk.Texture.new_from_filename(str(cover_path)).save_to_tiff(
|
||||
tmp_path := Gio.File.new_tmp("XXXXXX.tiff")[0].get_path()
|
||||
)
|
||||
return convert_cover(tmp_path)
|
||||
except GLib.GError:
|
||||
return None
|
||||
|
||||
return tmp_path
|
||||
|
||||
|
||||
def save_cover(game_id: str, cover_path: Path) -> None:
|
||||
shared.covers_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
animated_path = shared.covers_dir / f"{game_id}.gif"
|
||||
static_path = shared.covers_dir / f"{game_id}.tiff"
|
||||
|
||||
# Remove previous covers
|
||||
animated_path.unlink(missing_ok=True)
|
||||
static_path.unlink(missing_ok=True)
|
||||
|
||||
if not cover_path:
|
||||
return
|
||||
|
||||
copyfile(
|
||||
cover_path,
|
||||
animated_path if cover_path.suffix == ".gif" else static_path,
|
||||
)
|
||||
|
||||
if game_id in shared.win.game_covers:
|
||||
shared.win.game_covers[game_id].new_cover(
|
||||
animated_path if cover_path.suffix == ".gif" else static_path
|
||||
)
|
||||
37
cartridges/utils/sqlite.py
Normal file
37
cartridges/utils/sqlite.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# sqlite.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from glob import escape
|
||||
from pathlib import Path
|
||||
from shutil import copyfile
|
||||
|
||||
from gi.repository import GLib
|
||||
|
||||
|
||||
def copy_db(original_path: Path) -> Path:
|
||||
"""
|
||||
Copy a sqlite database to a cache dir and return its new path.
|
||||
The caller in in charge of deleting the returned path's parent dir.
|
||||
"""
|
||||
tmp = Path(GLib.Dir.make_tmp())
|
||||
for file in original_path.parent.glob(f"{escape(original_path.name)}*"):
|
||||
copy = tmp / file.name
|
||||
copyfile(str(file), str(copy))
|
||||
return tmp / original_path.name
|
||||
158
cartridges/utils/steam.py
Normal file
158
cartridges/utils/steam.py
Normal file
@@ -0,0 +1,158 @@
|
||||
# steam.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
import requests
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.utils.rate_limiter import RateLimiter
|
||||
|
||||
|
||||
class SteamError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SteamGameNotFoundError(SteamError):
|
||||
pass
|
||||
|
||||
|
||||
class SteamNotAGameError(SteamError):
|
||||
pass
|
||||
|
||||
|
||||
class SteamInvalidManifestError(SteamError):
|
||||
pass
|
||||
|
||||
|
||||
class SteamManifestData(TypedDict):
|
||||
"""Dict returned by SteamFileHelper.get_manifest_data"""
|
||||
|
||||
name: str
|
||||
appid: str
|
||||
stateflags: str
|
||||
|
||||
|
||||
class SteamAPIData(TypedDict):
|
||||
"""Dict returned by SteamAPIHelper.get_api_data"""
|
||||
|
||||
developer: str
|
||||
|
||||
|
||||
class SteamRateLimiter(RateLimiter):
|
||||
"""Rate limiter for the Steam web API"""
|
||||
|
||||
# Steam web API limit
|
||||
# 200 requests per 5 min seems to be the limit
|
||||
# https://stackoverflow.com/questions/76047820/how-am-i-exceeding-steam-apis-rate-limit
|
||||
# https://stackoverflow.com/questions/51795457/avoiding-error-429-too-many-requests-steam-web-api
|
||||
refill_period_seconds = 5 * 60
|
||||
refill_period_tokens = 200
|
||||
burst_tokens = 100
|
||||
|
||||
def _init_pick_history(self) -> None:
|
||||
"""
|
||||
Load the pick history from schema.
|
||||
|
||||
Allows remembering API limits through restarts of Cartridges.
|
||||
"""
|
||||
super()._init_pick_history()
|
||||
timestamps_str = shared.state_schema.get_string("steam-limiter-tokens-history")
|
||||
self.pick_history.add(*json.loads(timestamps_str))
|
||||
self.pick_history.remove_old_entries()
|
||||
|
||||
def acquire(self) -> None:
|
||||
"""Get a token from the bucket and store the pick history in the schema"""
|
||||
super().acquire()
|
||||
timestamps_str = json.dumps(self.pick_history.copy_timestamps())
|
||||
shared.state_schema.set_string("steam-limiter-tokens-history", timestamps_str)
|
||||
|
||||
|
||||
class SteamFileHelper:
|
||||
"""Helper for Steam file formats"""
|
||||
|
||||
def get_manifest_data(self, manifest_path: Path) -> SteamManifestData:
|
||||
"""Get local data for a game from its manifest"""
|
||||
|
||||
with open(manifest_path, "r", encoding="utf-8") as file:
|
||||
contents = file.read()
|
||||
|
||||
data = {}
|
||||
|
||||
for key in SteamManifestData.__required_keys__: # pylint: disable=no-member
|
||||
regex = f'"{key}"\\s+"(.*)"\n'
|
||||
if (match := re.search(regex, contents, re.IGNORECASE)) is None:
|
||||
raise SteamInvalidManifestError()
|
||||
data[key] = match.group(1)
|
||||
|
||||
return SteamManifestData(
|
||||
name=data["name"],
|
||||
appid=data["appid"],
|
||||
stateflags=data["stateflags"],
|
||||
)
|
||||
|
||||
|
||||
class SteamAPIHelper:
|
||||
"""Helper around the Steam API"""
|
||||
|
||||
base_url = "https://store.steampowered.com/api"
|
||||
rate_limiter: RateLimiter
|
||||
|
||||
def __init__(self, rate_limiter: RateLimiter) -> None:
|
||||
self.rate_limiter = rate_limiter
|
||||
|
||||
def get_api_data(self, appid: str) -> SteamAPIData:
|
||||
"""
|
||||
Get online data for a game from its appid.
|
||||
May block to satisfy the Steam web API limitations.
|
||||
|
||||
See https://wiki.teamfortress.com/wiki/User:RJackson/StorefrontAPI#appdetails
|
||||
"""
|
||||
|
||||
# Get data from the API (way block to satisfy its limits)
|
||||
with self.rate_limiter:
|
||||
try:
|
||||
with requests.get(
|
||||
f"{self.base_url}/appdetails?appids={appid}", timeout=5
|
||||
) as response:
|
||||
response.raise_for_status()
|
||||
data = response.json()[appid]
|
||||
except HTTPError as error:
|
||||
logging.warning("Steam API HTTP error for %s", appid, exc_info=error)
|
||||
raise error
|
||||
|
||||
# Handle not found
|
||||
if not data["success"]:
|
||||
logging.debug("Appid %s not found", appid)
|
||||
raise SteamGameNotFoundError()
|
||||
|
||||
# Handle appid is not a game
|
||||
if data["data"]["type"] not in {"game", "demo", "mod"}:
|
||||
logging.debug("Appid %s is not a game", appid)
|
||||
raise SteamNotAGameError()
|
||||
|
||||
# Return API values we're interested in
|
||||
values = SteamAPIData(developer=", ".join(data["data"]["developers"]))
|
||||
return values
|
||||
159
cartridges/utils/steamgriddb.py
Normal file
159
cartridges/utils/steamgriddb.py
Normal file
@@ -0,0 +1,159 @@
|
||||
# steamgriddb.py
|
||||
#
|
||||
# Copyright 2022-2023 kramo
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import requests
|
||||
from gi.repository import Gio
|
||||
from requests.exceptions import HTTPError
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.utils.save_cover import convert_cover, save_cover
|
||||
|
||||
|
||||
class SgdbError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SgdbAuthError(SgdbError):
|
||||
pass
|
||||
|
||||
|
||||
class SgdbGameNotFound(SgdbError):
|
||||
pass
|
||||
|
||||
|
||||
class SgdbBadRequest(SgdbError):
|
||||
pass
|
||||
|
||||
|
||||
class SgdbNoImageFound(SgdbError):
|
||||
pass
|
||||
|
||||
|
||||
class SgdbHelper:
|
||||
"""Helper class to make queries to SteamGridDB"""
|
||||
|
||||
base_url = "https://www.steamgriddb.com/api/v2/"
|
||||
|
||||
@property
|
||||
def auth_headers(self) -> dict[str, str]:
|
||||
key = shared.schema.get_string("sgdb-key")
|
||||
headers = {"Authorization": f"Bearer {key}"}
|
||||
return headers
|
||||
|
||||
def get_game_id(self, game: Game) -> Any:
|
||||
"""Get grid results for a game. Can raise an exception."""
|
||||
uri = f"{self.base_url}search/autocomplete/{game.name}"
|
||||
res = requests.get(uri, headers=self.auth_headers, timeout=5)
|
||||
match res.status_code:
|
||||
case 200:
|
||||
return res.json()["data"][0]["id"]
|
||||
case 401:
|
||||
raise SgdbAuthError(res.json()["errors"][0])
|
||||
case 404:
|
||||
raise SgdbGameNotFound(res.status_code)
|
||||
case _:
|
||||
res.raise_for_status()
|
||||
|
||||
def get_image_uri(self, game_id: str, animated: bool = False) -> Any:
|
||||
"""Get the image for a SGDB game id"""
|
||||
uri = f"{self.base_url}grids/game/{game_id}?dimensions=600x900"
|
||||
if animated:
|
||||
uri += "&types=animated"
|
||||
res = requests.get(uri, headers=self.auth_headers, timeout=5)
|
||||
match res.status_code:
|
||||
case 200:
|
||||
data = res.json()["data"]
|
||||
if len(data) == 0:
|
||||
raise SgdbNoImageFound()
|
||||
return data[0]["url"]
|
||||
case 401:
|
||||
raise SgdbAuthError(res.json()["errors"][0])
|
||||
case 404:
|
||||
raise SgdbGameNotFound(res.status_code)
|
||||
case _:
|
||||
res.raise_for_status()
|
||||
|
||||
def conditionaly_update_cover(self, game: Game) -> None:
|
||||
"""Update the game's cover if appropriate"""
|
||||
|
||||
# Obvious skips
|
||||
use_sgdb = shared.schema.get_boolean("sgdb")
|
||||
if not use_sgdb or game.blacklisted:
|
||||
return
|
||||
|
||||
image_trunk = shared.covers_dir / game.game_id
|
||||
still = image_trunk.with_suffix(".tiff")
|
||||
animated = image_trunk.with_suffix(".gif")
|
||||
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
|
||||
|
||||
# Do nothing if file present and not prefer SGDB
|
||||
if not prefer_sgdb and (still.is_file() or animated.is_file()):
|
||||
return
|
||||
|
||||
# Get ID for the game
|
||||
try:
|
||||
sgdb_id = self.get_game_id(game)
|
||||
except (HTTPError, SgdbError) as error:
|
||||
logging.warning(
|
||||
"%s while getting SGDB ID for %s", type(error).__name__, game.name
|
||||
)
|
||||
raise error
|
||||
|
||||
# Build different SGDB options to try
|
||||
image_uri_kwargs_sets = [{"animated": False}]
|
||||
if shared.schema.get_boolean("sgdb-animated"):
|
||||
image_uri_kwargs_sets.insert(0, {"animated": True})
|
||||
|
||||
# Download covers
|
||||
for uri_kwargs in image_uri_kwargs_sets:
|
||||
try:
|
||||
uri = self.get_image_uri(sgdb_id, **uri_kwargs)
|
||||
response = requests.get(uri, timeout=5)
|
||||
tmp_file = Gio.File.new_tmp()[0]
|
||||
tmp_file_path = tmp_file.get_path()
|
||||
Path(tmp_file_path).write_bytes(response.content)
|
||||
save_cover(game.game_id, convert_cover(tmp_file_path))
|
||||
except SgdbAuthError as error:
|
||||
# Let caller handle auth errors
|
||||
raise error
|
||||
except (HTTPError, SgdbError) as error:
|
||||
logging.warning(
|
||||
"%s while getting image for %s kwargs=%s",
|
||||
type(error).__name__,
|
||||
game.name,
|
||||
str(uri_kwargs),
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Stop as soon as one is finished
|
||||
return
|
||||
|
||||
# No image was added
|
||||
logging.warning(
|
||||
'No matching image found for game "%s" (SGDB ID %d)',
|
||||
game.name,
|
||||
sgdb_id,
|
||||
)
|
||||
raise SgdbNoImageFound()
|
||||
530
cartridges/window.py
Normal file
530
cartridges/window.py
Normal file
@@ -0,0 +1,530 @@
|
||||
# window.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 typing import Any, Optional
|
||||
|
||||
from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from cartridges import shared
|
||||
from cartridges.game import Game
|
||||
from cartridges.game_cover import GameCover
|
||||
from cartridges.utils.relative_date import relative_date
|
||||
|
||||
|
||||
@Gtk.Template(resource_path=shared.PREFIX + "/gtk/window.ui")
|
||||
class CartridgesWindow(Adw.ApplicationWindow):
|
||||
__gtype_name__ = "CartridgesWindow"
|
||||
|
||||
overlay_split_view = Gtk.Template.Child()
|
||||
navigation_view = Gtk.Template.Child()
|
||||
sidebar = Gtk.Template.Child()
|
||||
all_games_row_box = Gtk.Template.Child()
|
||||
all_games_no_label = Gtk.Template.Child()
|
||||
added_row_box = Gtk.Template.Child()
|
||||
added_games_no_label = Gtk.Template.Child()
|
||||
toast_overlay = Gtk.Template.Child()
|
||||
primary_menu_button = Gtk.Template.Child()
|
||||
show_sidebar_button = Gtk.Template.Child()
|
||||
details_view = Gtk.Template.Child()
|
||||
library_page = Gtk.Template.Child()
|
||||
library_view = Gtk.Template.Child()
|
||||
library = Gtk.Template.Child()
|
||||
scrolledwindow = Gtk.Template.Child()
|
||||
library_overlay = Gtk.Template.Child()
|
||||
notice_empty = Gtk.Template.Child()
|
||||
notice_no_results = Gtk.Template.Child()
|
||||
search_bar = Gtk.Template.Child()
|
||||
search_entry = Gtk.Template.Child()
|
||||
search_button = Gtk.Template.Child()
|
||||
|
||||
details_page = Gtk.Template.Child()
|
||||
details_view_toolbar_view = Gtk.Template.Child()
|
||||
details_view_cover = Gtk.Template.Child()
|
||||
details_view_spinner = Gtk.Template.Child()
|
||||
details_view_title = Gtk.Template.Child()
|
||||
details_view_blurred_cover = Gtk.Template.Child()
|
||||
details_view_play_button = Gtk.Template.Child()
|
||||
details_view_developer = Gtk.Template.Child()
|
||||
details_view_added = Gtk.Template.Child()
|
||||
details_view_last_played = Gtk.Template.Child()
|
||||
details_view_hide_button = Gtk.Template.Child()
|
||||
|
||||
hidden_library_page = Gtk.Template.Child()
|
||||
hidden_primary_menu_button = Gtk.Template.Child()
|
||||
hidden_library = Gtk.Template.Child()
|
||||
hidden_library_view = Gtk.Template.Child()
|
||||
hidden_scrolledwindow = Gtk.Template.Child()
|
||||
hidden_library_overlay = Gtk.Template.Child()
|
||||
hidden_notice_empty = Gtk.Template.Child()
|
||||
hidden_notice_no_results = Gtk.Template.Child()
|
||||
hidden_search_bar = Gtk.Template.Child()
|
||||
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"
|
||||
filter_state: str = "all"
|
||||
source_rows: dict = {}
|
||||
|
||||
def create_source_rows(self) -> None:
|
||||
def get_removed(source_id: str) -> Any:
|
||||
removed = tuple(
|
||||
game.removed or game.hidden or game.blacklisted
|
||||
for game in shared.store.source_games[source_id].values()
|
||||
)
|
||||
return (
|
||||
(count,) if (count := sum(removed)) != len(removed) else False
|
||||
) # Return a tuple because 0 == False and 1 == True
|
||||
|
||||
total_games_no = 0
|
||||
restored = False
|
||||
|
||||
selected_id = (
|
||||
self.source_rows[selected_row][0]
|
||||
if (selected_row := self.sidebar.get_selected_row()) in self.source_rows
|
||||
else None
|
||||
)
|
||||
|
||||
if selected_row == self.added_row_box.get_parent():
|
||||
self.sidebar.select_row(self.added_row_box.get_parent())
|
||||
restored = True
|
||||
|
||||
if added_missing := (
|
||||
not shared.store.source_games.get("imported")
|
||||
or not (removed := get_removed("imported"))
|
||||
):
|
||||
self.sidebar.select_row(self.all_games_row_box.get_parent())
|
||||
else:
|
||||
games_no = len(shared.store.source_games["imported"]) - removed[0]
|
||||
self.added_games_no_label.set_label(str(games_no))
|
||||
total_games_no += games_no
|
||||
self.added_row_box.get_parent().set_visible(not added_missing)
|
||||
|
||||
self.sidebar.get_row_at_index(2).set_visible(False)
|
||||
|
||||
while row := self.sidebar.get_row_at_index(3):
|
||||
self.sidebar.remove(row)
|
||||
|
||||
for source_id in shared.store.source_games:
|
||||
if source_id == "imported":
|
||||
continue
|
||||
if not (removed := get_removed(source_id)):
|
||||
continue
|
||||
|
||||
row = Gtk.Box(
|
||||
margin_top=12,
|
||||
margin_bottom=12,
|
||||
margin_start=6,
|
||||
margin_end=6,
|
||||
spacing=12,
|
||||
)
|
||||
games_no = len(shared.store.source_games[source_id]) - removed[0]
|
||||
total_games_no += games_no
|
||||
|
||||
row.append(
|
||||
Gtk.Image.new_from_icon_name(
|
||||
"user-desktop-symbolic"
|
||||
if (split_id := source_id.split("_")[0]) == "desktop"
|
||||
else f"{split_id}-source-symbolic"
|
||||
)
|
||||
)
|
||||
|
||||
row.append(
|
||||
Gtk.Label(
|
||||
label=self.get_application().get_source_name(source_id),
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
)
|
||||
|
||||
row.append(
|
||||
games_no_label := Gtk.Label(
|
||||
label=games_no,
|
||||
hexpand=True,
|
||||
halign=Gtk.Align.END,
|
||||
)
|
||||
)
|
||||
|
||||
games_no_label.add_css_class("dim-label")
|
||||
|
||||
# Order rows based on the number of games in them
|
||||
index = 3
|
||||
while source_row := self.sidebar.get_row_at_index(index):
|
||||
if self.source_rows[source_row][1] < games_no:
|
||||
self.sidebar.insert(row, index)
|
||||
break
|
||||
index += 1
|
||||
if not row.get_parent():
|
||||
self.sidebar.append(row)
|
||||
|
||||
self.source_rows[row.get_parent()] = (
|
||||
source_id,
|
||||
games_no,
|
||||
)
|
||||
|
||||
if source_id == selected_id:
|
||||
self.sidebar.select_row(row.get_parent())
|
||||
restored = True
|
||||
|
||||
self.sidebar.get_row_at_index(2).set_visible(True)
|
||||
|
||||
self.all_games_no_label.set_label(str(total_games_no))
|
||||
|
||||
if not restored:
|
||||
self.sidebar.select_row(self.all_games_row_box.get_parent())
|
||||
|
||||
def row_selected(self, _widget: Any, row: Gtk.ListBoxRow | None) -> None:
|
||||
if not row:
|
||||
return
|
||||
match row.get_child():
|
||||
case self.all_games_row_box:
|
||||
value = "all"
|
||||
case self.added_row_box:
|
||||
value = "imported"
|
||||
case _:
|
||||
value = self.source_rows[row][0]
|
||||
|
||||
self.library_page.set_title(self.get_application().get_source_name(value))
|
||||
|
||||
self.filter_state = value
|
||||
self.library.invalidate_filter()
|
||||
|
||||
if self.overlay_split_view.get_collapsed():
|
||||
self.overlay_split_view.set_show_sidebar(False)
|
||||
|
||||
def __init__(self, **kwargs: Any) -> None:
|
||||
super().__init__(**kwargs)
|
||||
|
||||
self.details_view.set_measure_overlay(self.details_view_toolbar_view, True)
|
||||
self.details_view.set_clip_overlay(self.details_view_toolbar_view, False)
|
||||
|
||||
self.library.set_filter_func(self.filter_func)
|
||||
self.hidden_library.set_filter_func(self.filter_func)
|
||||
|
||||
self.library.set_sort_func(self.sort_func)
|
||||
self.hidden_library.set_sort_func(self.sort_func)
|
||||
|
||||
self.set_library_child()
|
||||
|
||||
self.notice_empty.set_icon_name(shared.APP_ID + "-symbolic")
|
||||
|
||||
self.overlay_split_view.set_show_sidebar(
|
||||
shared.state_schema.get_boolean("show-sidebar")
|
||||
)
|
||||
|
||||
self.sidebar.select_row(self.all_games_row_box.get_parent())
|
||||
|
||||
if shared.PROFILE == "development":
|
||||
self.add_css_class("devel")
|
||||
|
||||
# Connect search entries
|
||||
self.search_bar.connect_entry(self.search_entry)
|
||||
self.hidden_search_bar.connect_entry(self.hidden_search_entry)
|
||||
|
||||
# Connect signals
|
||||
self.search_entry.connect("search-changed", self.search_changed, False)
|
||||
self.hidden_search_entry.connect("search-changed", self.search_changed, True)
|
||||
|
||||
self.search_entry.connect("activate", self.show_details_page_search)
|
||||
self.hidden_search_entry.connect("activate", self.show_details_page_search)
|
||||
|
||||
self.navigation_view.connect("popped", self.set_show_hidden)
|
||||
self.navigation_view.connect("pushed", self.set_show_hidden)
|
||||
|
||||
self.sidebar.connect("row-selected", self.row_selected)
|
||||
|
||||
style_manager = Adw.StyleManager.get_default()
|
||||
style_manager.connect("notify::dark", self.set_details_view_opacity)
|
||||
style_manager.connect("notify::high-contrast", self.set_details_view_opacity)
|
||||
|
||||
# Allow for a custom number of rows for the library
|
||||
if shared.schema.get_uint("library-rows"):
|
||||
shared.schema.bind(
|
||||
"library-rows",
|
||||
self.library,
|
||||
"max-children-per-line",
|
||||
Gio.SettingsBindFlags.DEFAULT,
|
||||
)
|
||||
shared.schema.bind(
|
||||
"library-rows",
|
||||
self.hidden_library,
|
||||
"max-children-per-line",
|
||||
Gio.SettingsBindFlags.DEFAULT,
|
||||
)
|
||||
|
||||
def search_changed(self, _widget: Any, hidden: bool) -> None:
|
||||
# Refresh search filter on keystroke in search box
|
||||
(self.hidden_library if hidden else self.library).invalidate_filter()
|
||||
|
||||
def set_library_child(self) -> None:
|
||||
child, hidden_child = self.notice_empty, self.hidden_notice_empty
|
||||
|
||||
for game in shared.store:
|
||||
if game.removed or game.blacklisted:
|
||||
continue
|
||||
if game.hidden:
|
||||
if game.filtered and hidden_child:
|
||||
hidden_child = self.hidden_notice_no_results
|
||||
continue
|
||||
hidden_child = None
|
||||
else:
|
||||
if game.filtered and child:
|
||||
child = self.notice_no_results
|
||||
continue
|
||||
child = None
|
||||
|
||||
def remove_from_overlay(widget: Gtk.Widget) -> None:
|
||||
if isinstance(widget.get_parent(), Gtk.Overlay):
|
||||
widget.get_parent().remove_overlay(widget)
|
||||
|
||||
if child:
|
||||
self.library_overlay.add_overlay(child)
|
||||
else:
|
||||
remove_from_overlay(self.notice_empty)
|
||||
remove_from_overlay(self.notice_no_results)
|
||||
|
||||
if hidden_child:
|
||||
self.hidden_library_overlay.add_overlay(hidden_child)
|
||||
else:
|
||||
remove_from_overlay(self.hidden_notice_empty)
|
||||
remove_from_overlay(self.hidden_notice_no_results)
|
||||
|
||||
def filter_func(self, child: Gtk.Widget) -> bool:
|
||||
game = child.get_child()
|
||||
text = (
|
||||
(
|
||||
self.hidden_search_entry
|
||||
if self.navigation_view.get_visible_page() == self.hidden_library_page
|
||||
else self.search_entry
|
||||
)
|
||||
.get_text()
|
||||
.lower()
|
||||
)
|
||||
|
||||
filtered = text != "" and not (
|
||||
text in game.name.lower()
|
||||
or (text in game.developer.lower() if game.developer else False)
|
||||
)
|
||||
|
||||
if not filtered:
|
||||
if self.filter_state == "all":
|
||||
pass
|
||||
elif game.base_source != self.filter_state:
|
||||
filtered = True
|
||||
|
||||
game.filtered = filtered
|
||||
self.set_library_child()
|
||||
|
||||
return not filtered
|
||||
|
||||
def set_active_game(self, _widget: Any, _pspec: Any, game: Game) -> None:
|
||||
self.active_game = game
|
||||
|
||||
def show_details_page(self, game: Game) -> None:
|
||||
self.active_game = game
|
||||
|
||||
self.details_view_cover.set_opacity(int(not game.loading))
|
||||
self.details_view_spinner.set_spinning(game.loading)
|
||||
|
||||
self.details_view_developer.set_label(game.developer or "")
|
||||
self.details_view_developer.set_visible(bool(game.developer))
|
||||
|
||||
icon, text = "view-conceal-symbolic", _("Hide")
|
||||
if game.hidden:
|
||||
icon, text = "view-reveal-symbolic", _("Unhide")
|
||||
|
||||
self.details_view_hide_button.set_icon_name(icon)
|
||||
self.details_view_hide_button.set_tooltip_text(text)
|
||||
|
||||
if self.details_view_game_cover:
|
||||
self.details_view_game_cover.pictures.remove(self.details_view_cover)
|
||||
|
||||
self.details_view_game_cover = game.game_cover
|
||||
self.details_view_game_cover.add_picture(self.details_view_cover)
|
||||
|
||||
self.details_view_blurred_cover.set_paintable(
|
||||
self.details_view_game_cover.get_blurred()
|
||||
)
|
||||
|
||||
self.details_view_title.set_label(game.name)
|
||||
self.details_page.set_title(game.name)
|
||||
|
||||
date = relative_date(game.added)
|
||||
self.details_view_added.set_label(
|
||||
# The variable is the date when the game was added
|
||||
_("Added: {}").format(date)
|
||||
)
|
||||
last_played_date = (
|
||||
relative_date(game.last_played) if game.last_played else _("Never")
|
||||
)
|
||||
self.details_view_last_played.set_label(
|
||||
# The variable is the date when the game was last played
|
||||
_("Last played: {}").format(last_played_date)
|
||||
)
|
||||
|
||||
if self.navigation_view.get_visible_page() != self.details_page:
|
||||
self.navigation_view.push(self.details_page)
|
||||
self.set_focus(self.details_view_play_button)
|
||||
|
||||
self.set_details_view_opacity()
|
||||
|
||||
def set_details_view_opacity(self, *_args: Any) -> None:
|
||||
if self.navigation_view.get_visible_page() != self.details_page:
|
||||
return
|
||||
|
||||
if (
|
||||
style_manager := Adw.StyleManager.get_default()
|
||||
).get_high_contrast() or not style_manager.get_system_supports_color_schemes():
|
||||
self.details_view_blurred_cover.set_opacity(0.3)
|
||||
return
|
||||
|
||||
self.details_view_blurred_cover.set_opacity(
|
||||
1 - self.details_view_game_cover.luminance[0] # type: ignore
|
||||
if style_manager.get_dark()
|
||||
else self.details_view_game_cover.luminance[1] # type: ignore
|
||||
)
|
||||
|
||||
def sort_func(self, child1: Gtk.Widget, child2: Gtk.Widget) -> int:
|
||||
var, order = "name", True
|
||||
|
||||
if self.sort_state in ("newest", "oldest"):
|
||||
var, order = "added", self.sort_state == "newest"
|
||||
elif self.sort_state == "last_played":
|
||||
var = "last_played"
|
||||
elif self.sort_state == "a-z":
|
||||
order = False
|
||||
|
||||
def get_value(index: int) -> str:
|
||||
return str(
|
||||
getattr((child1.get_child(), child2.get_child())[index], var)
|
||||
).lower()
|
||||
|
||||
if var != "name" and get_value(0) == get_value(1):
|
||||
var, order = "name", False
|
||||
|
||||
return ((get_value(0) > get_value(1)) ^ order) * 2 - 1
|
||||
|
||||
def set_show_hidden(self, navigation_view: Adw.NavigationView, *_args: Any) -> None:
|
||||
self.lookup_action("show_hidden").set_enabled(
|
||||
navigation_view.get_visible_page() == self.library_page
|
||||
)
|
||||
|
||||
def on_show_sidebar_action(self, *_args: Any) -> None:
|
||||
shared.state_schema.set_boolean(
|
||||
"show-sidebar", (value := not self.overlay_split_view.get_show_sidebar())
|
||||
)
|
||||
self.overlay_split_view.set_show_sidebar(value)
|
||||
|
||||
def on_go_to_parent_action(self, *_args: Any) -> None:
|
||||
if self.navigation_view.get_visible_page() == self.details_page:
|
||||
self.navigation_view.pop()
|
||||
|
||||
def on_go_home_action(self, *_args: Any) -> None:
|
||||
self.navigation_view.pop_to_page(self.library_page)
|
||||
|
||||
def on_show_hidden_action(self, *_args: Any) -> None:
|
||||
self.navigation_view.push(self.hidden_library_page)
|
||||
|
||||
def on_sort_action(self, action: Gio.SimpleAction, state: GLib.Variant) -> None:
|
||||
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:
|
||||
if self.navigation_view.get_visible_page() == self.library_page:
|
||||
search_bar = self.search_bar
|
||||
search_entry = self.search_entry
|
||||
elif self.navigation_view.get_visible_page() == self.hidden_library_page:
|
||||
search_bar = self.hidden_search_bar
|
||||
search_entry = self.hidden_search_entry
|
||||
else:
|
||||
return
|
||||
|
||||
search_bar.set_search_mode(not (search_mode := search_bar.get_search_mode()))
|
||||
|
||||
if not search_mode:
|
||||
self.set_focus(search_entry)
|
||||
|
||||
search_entry.set_text("")
|
||||
|
||||
def on_escape_action(self, *_args: Any) -> None:
|
||||
if (
|
||||
self.get_focus() == self.search_entry.get_focus_child()
|
||||
or self.hidden_search_entry.get_focus_child()
|
||||
):
|
||||
self.on_toggle_search_action()
|
||||
else:
|
||||
self.navigation_view.pop()
|
||||
|
||||
def show_details_page_search(self, widget: Gtk.Widget) -> None:
|
||||
library = (
|
||||
self.hidden_library if widget == self.hidden_search_entry else self.library
|
||||
)
|
||||
index = 0
|
||||
|
||||
while True:
|
||||
if not (child := library.get_child_at_index(index)):
|
||||
break
|
||||
|
||||
if self.filter_func(child):
|
||||
self.show_details_page(child.get_child())
|
||||
break
|
||||
|
||||
index += 1
|
||||
|
||||
def on_undo_action(
|
||||
self, _widget: Any, game: Optional[Game] = None, undo: Optional[str] = None
|
||||
) -> None:
|
||||
if not game: # If the action was activated via Ctrl + Z
|
||||
if 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)
|
||||
|
||||
elif undo == "remove":
|
||||
game.removed = False
|
||||
game.save()
|
||||
game.update()
|
||||
|
||||
self.toasts[(game, undo)].dismiss()
|
||||
self.toasts.pop((game, undo))
|
||||
|
||||
def on_open_menu_action(self, *_args: Any) -> None:
|
||||
if self.navigation_view.get_visible_page() == self.library_page:
|
||||
self.primary_menu_button.popup()
|
||||
elif self.navigation_view.get_visible_page() == self.hidden_library_page:
|
||||
self.hidden_primary_menu_button.popup()
|
||||
|
||||
def on_close_action(self, *_args: Any) -> None:
|
||||
self.close()
|
||||
Reference in New Issue
Block a user