🚧 Initial work on Steam source

This commit is contained in:
GeoffreyCoulaud
2023-05-20 19:48:03 +02:00
parent 08c4b53cca
commit 604bcfb2e9
6 changed files with 233 additions and 15 deletions

View File

@@ -16,6 +16,12 @@
<key name="steam-location" type="s">
<default>"~/.steam/"</default>
</key>
<key name="steam-flatpak-location" type="s">
<default>~/.var/app/com.valvesoftware.Steam/data/Steam/</default>
</key>
<key name="steam-windows-location" type="s">
<default>"C:\Program Files (x86)\Steam"</default>
</key>
<key name="lutris" type="b">
<default>true</default>
</key>

View File

@@ -116,6 +116,8 @@ class Importer:
exc_info=exception,
)
continue
if game is None:
continue
# TODO register in store instead of dict

View File

@@ -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"""

View File

@@ -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()

View File

@@ -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):

View File

@@ -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