From 604bcfb2e92e20d01c9c6217da823b31d9112302 Mon Sep 17 00:00:00 2001 From: GeoffreyCoulaud Date: Sat, 20 May 2023 19:48:03 +0200 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=A7=20Initial=20work=20on=20Steam=20so?= =?UTF-8?q?urce?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- data/hu.kramo.Cartridges.gschema.xml | 6 + src/importer/importer.py | 2 + src/importer/source.py | 31 ++--- src/importer/sources/steam_source.py | 180 +++++++++++++++++++++++++++ src/main.py | 9 ++ src/utils/decorators.py | 20 ++- 6 files changed, 233 insertions(+), 15 deletions(-) create mode 100644 src/importer/sources/steam_source.py diff --git a/data/hu.kramo.Cartridges.gschema.xml b/data/hu.kramo.Cartridges.gschema.xml index f4a9368..1230c42 100644 --- a/data/hu.kramo.Cartridges.gschema.xml +++ b/data/hu.kramo.Cartridges.gschema.xml @@ -16,6 +16,12 @@ "~/.steam/" + + ~/.var/app/com.valvesoftware.Steam/data/Steam/ + + + "C:\Program Files (x86)\Steam" + true diff --git a/src/importer/importer.py b/src/importer/importer.py index 42aac19..2f1e5ae 100644 --- a/src/importer/importer.py +++ b/src/importer/importer.py @@ -116,6 +116,8 @@ class Importer: exc_info=exception, ) continue + if game is None: + continue # TODO register in store instead of dict diff --git a/src/importer/source.py b/src/importer/source.py index a2fca0c..663b92e 100644 --- a/src/importer/source.py +++ b/src/importer/source.py @@ -1,35 +1,38 @@ from abc import abstractmethod from collections.abc import Iterable, Iterator, Sized +from src.game import Game +from src.window import CartridgesWindow + class SourceIterator(Iterator, Sized): """Data producer for a source of games""" - source = None + source: "Source" = None - def __init__(self, source) -> None: + def __init__(self, source: "Source") -> None: super().__init__() self.source = source - def __iter__(self): + def __iter__(self) -> "SourceIterator": return self @abstractmethod - def __len__(self): + def __len__(self) -> int: """Get a rough estimate of the number of games produced by the source""" @abstractmethod - def __next__(self): + def __next__(self) -> "Game" | None: """Get the next generated game from the source. Raises StopIteration when exhausted. - May raise any other exception signifying an error on this specific game.""" + May raise any other exception signifying an error on this specific game. + May return None when a game has been skipped without an error.""" class Source(Iterable): """Source of games. E.g an installed app with a config file that lists game directories""" - win = None - + win: "CartridgesWindow" = None name: str variant: str @@ -38,7 +41,7 @@ class Source(Iterable): self.win = win @property - def full_name(self): + def full_name(self) -> str: """The source's full name""" full_name_ = self.name if self.variant is not None: @@ -46,7 +49,7 @@ class Source(Iterable): return full_name_ @property - def id(self): # pylint: disable=invalid-name + def id(self) -> str: # pylint: disable=invalid-name """The source's identifier""" id_ = self.name.lower() if self.variant is not None: @@ -54,7 +57,7 @@ class Source(Iterable): return id_ @property - def game_id_format(self): + def game_id_format(self) -> str: """The string format used to construct game IDs""" format_ = self.name.lower() if self.variant is not None: @@ -64,14 +67,14 @@ class Source(Iterable): @property @abstractmethod - def executable_format(self): + def executable_format(self) -> str: """The executable format used to construct game executables""" @property @abstractmethod - def is_installed(self): + def is_installed(self) -> bool: """Whether the source is detected as installed""" @abstractmethod - def __iter__(self): + def __iter__(self) -> SourceIterator: """Get the source's iterator, to use in for loops""" diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py new file mode 100644 index 0000000..d548009 --- /dev/null +++ b/src/importer/sources/steam_source.py @@ -0,0 +1,180 @@ +import re +import logging +from time import time +from pathlib import Path + +import requests +from requests import HTTPError, JSONDecodeError + +from src.game import Game +from src.importer.source import Source, SourceIterator +from src.utils.decorators import ( + replaced_by_path, + replaced_by_schema_key, + replaced_by_env_path, +) +from src.utils.save_cover import resize_cover, save_cover + + +class SteamAPIError(Exception): + pass + + +class SteamSourceIterator(SourceIterator): + source: "SteamSource" + + manifests = None + manifests_iterator = None + + installed_state_mask = 4 + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + self.manifests = set() + + # Get dirs that contain steam app manifests + manifests_dirs = set() + libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf" + with open(libraryfolders_path, "r") as file: + for line in file.readlines(): + line = line.strip() + prefix = '"path"' + if not line.startswith(prefix): + continue + library_folder = Path(line[len(prefix) :].strip()[1:-1]) + manifests_dir = library_folder / "steamapps" + if not (manifests_dir).is_dir(): + continue + manifests_dirs.add(manifests_dir) + + # Get app manifests + for manifests_dir in manifests_dirs: + for child in manifests_dir.iterdir(): + if child.is_file() and "appmanifest" in child.name: + self.manifests.add(child) + + self.manifests_iterator = iter(self.manifests) + + def __len__(self): + return len(self.manifests) + + def __next__(self): + # Get metadata from manifest + # Ignore manifests that don't have a value for all keys + manifest = next(self.manifests_iterator) + manifest_data = {"name": None, "appid": None, "StateFlags": "0"} + try: + with open(manifest) as file: + contents = file.read() + for key in manifest_data: + regex = f'"{key}"\s+"(.*)"\n' + if (match := re.search(regex, contents)) is None: + return None + manifest_data[key] = match.group(1) + except OSError: + return None + + # Skip non installed games + if not int(manifest_data["StateFlags"]) & self.installed_state_mask: + return None + + # Build basic game + appid = manifest_data["appid"] + values = { + "added": int(time()), + "name": manifest_data["name"], + "hidden": False, + "source": self.source.id, + "game_id": self.source.game_id_format.format(game_id=appid), + "executable": self.source.executable_format.format(game_id=appid), + "blacklisted": False, + "developer": None, + } + game = Game(self.source.win, values, allow_side_effects=False) + + # Add official cover image + cover_path = ( + self.source.location + / "appcache" + / "librarycache" + / f"{appid}_library_600x900.jpg" + ) + if cover_path.is_file(): + save_cover(self.win, game.game_id, resize_cover(self.win, cover_path)) + + # Make Steam API call + try: + with requests.get( + "https://store.steampowered.com/api/appdetails?appids=%s" + % manifest_data["appid"], + timeout=5, + ) as response: + response.raise_for_status() + steam_api_data = response.json()[appid] + except (HTTPError, JSONDecodeError) as error: + logging.warning( + "Error while querying Steam API for %s (%s)", + manifest_data["name"], + manifest_data["appid"], + exc_info=error, + ) + return game + + # Fill out new values + if not steam_api_data["success"] or steam_api_data["data"]["type"] != "game": + values["blacklisted"] = True + else: + values["developer"] = ", ".join(steam_api_data["data"]["developers"]) + game.update_values(values) + return game + + +class SteamSource(Source): + name = "Steam" + executable_format = "xdg-open steam://rungameid/{game_id}" + location = None + + @property + def is_installed(self): + # pylint: disable=pointless-statement + try: + self.location + except FileNotFoundError: + return False + return True + + def __iter__(self): + return SteamSourceIterator(source=self) + + +class SteamNativeSource(SteamSource): + variant = "native" + + @property + @replaced_by_schema_key("steam-location") + @replaced_by_env_path("XDG_DATA_HOME", "Steam/") + @replaced_by_path("~/.steam/") + @replaced_by_path("~/.local/share/Steam/") + def location(self): + raise FileNotFoundError() + + +class SteamFlatpakSource(SteamSource): + variant = "flatpak" + + @property + @replaced_by_schema_key("steam-flatpak-location") + @replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/") + def location(self): + raise FileNotFoundError() + + +class SteamWindowsSource(SteamSource): + variant = "windows" + + @property + @replaced_by_schema_key("steam-windows-location") + @replaced_by_env_path("programfiles(x86)", "Steam") + def location(self): + raise FileNotFoundError() diff --git a/src/main.py b/src/main.py index 37ffe9a..9d3a3b8 100644 --- a/src/main.py +++ b/src/main.py @@ -36,6 +36,11 @@ from src.importer.sources.lutris_source import ( LutrisFlatpakSource, LutrisNativeSource, ) +from src.importer.sources.steam_source import ( + SteamNativeSource, + SteamFlatpakSource, + SteamWindowsSource, +) from src.preferences import PreferencesWindow from src.window import CartridgesWindow @@ -156,6 +161,10 @@ class CartridgesApplication(Adw.Application): if self.win.schema.get_boolean("lutris"): importer.add_source(LutrisNativeSource(self.win)) importer.add_source(LutrisFlatpakSource(self.win)) + if self.win.schema.get_boolean("steam"): + importer.add_source(SteamNativeSource(self.win)) + importer.add_source(SteamFlatpakSource(self.win)) + importer.add_source(SteamWindowsSource(self.win)) importer.run() def on_remove_game_action(self, *_args): diff --git a/src/utils/decorators.py b/src/utils/decorators.py index 0ec1ea7..456c450 100644 --- a/src/utils/decorators.py +++ b/src/utils/decorators.py @@ -14,7 +14,7 @@ class MyClass(): """ from pathlib import Path -from os import PathLike +from os import PathLike, environ from functools import wraps @@ -52,3 +52,21 @@ def replaced_by_schema_key(key: str): # Decorator builder return wrapper return decorator + + +def replaced_by_env_path(env_var_name: str, suffix: PathLike | None = None): + """Replace the method's returned path with a path whose root is the env variable""" + + def decorator(original_function): # Built decorator (closure) + @wraps(original_function) + def wrapper(*args, **kwargs): # func's override + try: + env_var = environ[env_var_name] + except KeyError: + return original_function(*args, **kwargs) + override = Path(env_var) / suffix + return replaced_by_path(override)(original_function)(*args, **kwargs) + + return wrapper + + return decorator