From 524a56ea9ad53d96fb2dbebeac00c86526bfaf46 Mon Sep 17 00:00:00 2001 From: GeoffreyCoulaud Date: Sun, 30 Apr 2023 00:49:41 +0200 Subject: [PATCH] WIP - Work on base classes --- src/game2.py | 30 +++++++ src/importer/__init__.py | 0 src/importer/importer.py | 80 ++++++++++++++++++ src/importer/location.py | 20 +++++ src/importer/source.py | 60 ++++++++++++++ src/importer/sources/__init__.py | 0 src/importer/sources/lutris_source.py | 115 ++++++++++++++++++++++++++ src/managers/__init__.py | 0 src/managers/game_manager.py | 14 ++++ 9 files changed, 319 insertions(+) create mode 100644 src/game2.py create mode 100644 src/importer/__init__.py create mode 100644 src/importer/importer.py create mode 100644 src/importer/location.py create mode 100644 src/importer/source.py create mode 100644 src/importer/sources/__init__.py create mode 100644 src/importer/sources/lutris_source.py create mode 100644 src/managers/__init__.py create mode 100644 src/managers/game_manager.py diff --git a/src/game2.py b/src/game2.py new file mode 100644 index 0000000..15b4f93 --- /dev/null +++ b/src/game2.py @@ -0,0 +1,30 @@ +from dataclasses import dataclass, field +from time import time + + +@dataclass +class Game(): + """Simple game class that contains the necessary fields. + Saving, updating and removing is done by game manager classes.""" + + # State + removed : bool = field(default=False, init=False) + blacklisted: bool = field(default=False, init=False) + added : int = field(default=-1, init=False) + last_played: int = field(default=-1, init=False) + + # Metadata + source : str = None + name : str = None + game_id : str = None + developer : str = None + + # Launching + executable : str = None + + # Display + game_cover : str = None + hidden : bool = False + + def __post_init__(self): + self.added = int(time()) \ No newline at end of file diff --git a/src/importer/__init__.py b/src/importer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/importer/importer.py b/src/importer/importer.py new file mode 100644 index 0000000..7ca3b07 --- /dev/null +++ b/src/importer/importer.py @@ -0,0 +1,80 @@ +from gi.repository import Adw, Gtk, Gio + +class Importer(): + + # Display values + win = None + progressbar = None + import_statuspage = None + import_dialog = None + + # Importer values + count_total = 0 + count_done = 0 + sources = list() + + def __init__(self, win) -> None: + self.win = win + + def create_dialog(self): + """Create the import dialog""" + self.progressbar = Gtk.ProgressBar(margin_start=12, margin_end=12) + self.import_statuspage = Adw.StatusPage( + title=_("Importing Games…"), + child=self.progressbar, + ) + self.import_dialog = Adw.Window( + content=self.import_statuspage, + modal=True, + default_width=350, + default_height=-1, + transient_for=self.win, + deletable=False, + ) + self.import_dialog.present() + + def close_dialog(self): + """Close the import dialog""" + self.import_dialog.close() + + def get_progress(self): + """Get the current progression as a number between 0 and 1""" + progress = 1 + if self.total_queue > 0: + progress = 1 - self.queue / self.total_queue + return progress + + def update_progressbar(self): + """Update the progress bar""" + progress = self.get_progress() + self.progressbar.set_fraction(progress) + + def add_source(self, source): + """Add a source to import games from""" + self.sources.append(source) + + def import_games(self): + """Import games from the specified sources""" + self.create_dialog() + + # TODO make that async, you doofus + # Every source does its job on the side, informing of the amount of work and when a game is done. + # At the end of the task, it returns the games. + + # Idea 1 - Work stealing queue + # 1. Sources added to the queue + # 2. Worker A takes source X and advances it + # 3. Worker A puts back source X to the queue + # 4. Worker B takes source X, that has ended + # 5. Worker B doesn't add source X back to the queue + + # Idea 2 - Gio.Task + # 1. A task is created for every source + # 2. Source X finishes + # 3. Importer adds the games + + for source in self.sources: + for game in source: + game.save() + + self.close_dialog() \ No newline at end of file diff --git a/src/importer/location.py b/src/importer/location.py new file mode 100644 index 0000000..5aafb83 --- /dev/null +++ b/src/importer/location.py @@ -0,0 +1,20 @@ +from dataclasses import dataclass +from functools import cached_property +from pathlib import Path + + +@dataclass +class Location(): + """Abstraction for a location that can be overriden by a schema key""" + + win = None + + default: str = None + key: str = None + + @cached_property + def path(self): + override = Path(self.win.schema.get_string(self.path_override_key)) + if override.exists(): + return override + return self.path_default \ No newline at end of file diff --git a/src/importer/source.py b/src/importer/source.py new file mode 100644 index 0000000..0b4517b --- /dev/null +++ b/src/importer/source.py @@ -0,0 +1,60 @@ +from collections.abc import Iterable, Iterator +from enum import IntEnum, auto + + +class SourceIterator(Iterator): + """Data producer for a source of games""" + + class States(IntEnum): + DEFAULT = auto() + READY = auto() + + state = States.DEFAULT + source = None + + def __init__(self, source) -> None: + super().__init__() + self.source = source + + def __iter__(self): + return self + + def __next__(self): + raise NotImplementedError() + + +class Source(Iterable): + """Source of games. Can be a program location on disk with a config file that points to game for example""" + + win = None + name: str = "GenericSource" + variant: str = None + + # Format to construct the executable command for a game. + # Available field names depend on the implementation + executable_format: str + + def __init__(self, win) -> None: + super().__init__() + self.win = win + + @property + def full_name(self): + """Get the source's full name""" + s = self.name + if self.variant is not None: + s += " (%s)" % self.variant + return s + + @property + def game_id_format(self): + """Get the string format used to construct game IDs""" + _format = self.name.lower() + if self.variant is not None: + _format += "_" + self.variant.lower() + _format += "_{game_id}_{game_internal_id}" + return _format + + def __iter__(self): + """Get the source's iterator, to use in for loops""" + raise NotImplementedError() \ No newline at end of file diff --git a/src/importer/sources/__init__.py b/src/importer/sources/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py new file mode 100644 index 0000000..fa6d7f6 --- /dev/null +++ b/src/importer/sources/lutris_source.py @@ -0,0 +1,115 @@ +from pathlib import Path +from functools import cached_property +from sqlite3 import connect + +from cartridges.game2 import Game +from cartridges.importer.source import Source, SourceIterator + + +class LutrisSourceIterator(SourceIterator): + + ignore_steam_games = False + + db_connection = None + db_cursor = None + db_location = None + db_request = None + + def __init__(self, ignore_steam_games) -> None: + super().__init__() + self.ignore_steam_games = ignore_steam_games + self.db_connection = None + self.db_cursor = None + self.db_location = self.source.location / "pga.db" + self.db_request = """ + SELECT + id, name, slug, runner, hidden + FROM + 'games' + WHERE + name IS NOT NULL + AND slug IS NOT NULL + AND configPath IS NOT NULL + AND installed IS TRUE + ; + """ + + def __next__(self): + """Produce games. Behaviour depends on the state of the iterator.""" + + # Get database contents iterator + if self.state == self.States.DEFAULT: + self.db_connection = connect(self.db_location) + self.db_cursor = self.db_connection.execute(self.db_request) + self.state = self.States.READY + + # Get next DB value + while True: + try: + row = self.db_cursor.__next__() + except StopIteration as e: + self.db_connection.close() + raise e + + # Ignore steam games if requested + if row[3] == "steam" and self.ignore_steam_games: + continue + + # Build basic game + game = Game( + name=row[1], + hidden=row[4], + source=self.source.full_name, + game_id=self.source.game_id_format.format(game_id=row[2]), + executable=self.source.executable_format.format(game_id=row[2]), + developer=None, # TODO get developer metadata on Lutris + ) + # TODO Add official image + # TODO Add SGDB image + return game + +class LutrisSource(Source): + + name = "Lutris" + executable_format = "xdg-open lutris:rungameid/{game_id}" + + location = None + cache_location = None + + def __iter__(self): + return LutrisSourceIterator(source=self) + + # TODO find a way to no duplicate this code + # Ideas: + # - Location class (verbose, set in __init__) + # - Schema key override decorator () + + # Lutris location property + @cached_property + def location(self): + ovr = Path(self.win.schema.get_string(self.location_key)) + if ovr.exists(): return ovr + return self.location_default + + # Lutris cache location property + @cached_property + def cache_location(self): + ovr = Path(self.win.schema.get_string(self.cache_location_key)) + if ovr.exists(): return ovr + return self.cache_location_default + + +class LutrisNativeSource(LutrisSource): + variant = "native" + location_key = "lutris-location" + location_default = Path("~/.local/share/lutris/").expanduser() + cache_location_key = "lutris-cache-location" + cache_location_default = location_default / "covers" + + +class LutrisFlatpakSource(LutrisSource): + variant = "flatpak" + location_key = "lutris-flatpak-location" + location_default = Path("~/.var/app/net.lutris.Lutris/data/lutris").expanduser() + cache_location_key = "lutris-flatpak-cache-location" + cache_location_default = location_default / "covers" \ No newline at end of file diff --git a/src/managers/__init__.py b/src/managers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/managers/game_manager.py b/src/managers/game_manager.py new file mode 100644 index 0000000..9af60b4 --- /dev/null +++ b/src/managers/game_manager.py @@ -0,0 +1,14 @@ +class GameManager(): + """Interface for systems that save, update and remove games""" + + def add(self, game): + """Add a game to the manager""" + raise NotImplementedError() + + def update(self, game): + """Update an existing game in the manager""" + raise NotImplementedError() + + def remove(self, game): + """Remove an existing game from the manager""" + raise NotImplementedError() \ No newline at end of file