🚧 Initial work on Steam source
This commit is contained in:
@@ -16,6 +16,12 @@
|
|||||||
<key name="steam-location" type="s">
|
<key name="steam-location" type="s">
|
||||||
<default>"~/.steam/"</default>
|
<default>"~/.steam/"</default>
|
||||||
</key>
|
</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">
|
<key name="lutris" type="b">
|
||||||
<default>true</default>
|
<default>true</default>
|
||||||
</key>
|
</key>
|
||||||
|
|||||||
@@ -116,6 +116,8 @@ class Importer:
|
|||||||
exc_info=exception,
|
exc_info=exception,
|
||||||
)
|
)
|
||||||
continue
|
continue
|
||||||
|
if game is None:
|
||||||
|
continue
|
||||||
|
|
||||||
# TODO register in store instead of dict
|
# TODO register in store instead of dict
|
||||||
|
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Iterable, Iterator, Sized
|
from collections.abc import Iterable, Iterator, Sized
|
||||||
|
|
||||||
|
from src.game import Game
|
||||||
|
from src.window import CartridgesWindow
|
||||||
|
|
||||||
|
|
||||||
class SourceIterator(Iterator, Sized):
|
class SourceIterator(Iterator, Sized):
|
||||||
"""Data producer for a source of games"""
|
"""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__()
|
super().__init__()
|
||||||
self.source = source
|
self.source = source
|
||||||
|
|
||||||
def __iter__(self):
|
def __iter__(self) -> "SourceIterator":
|
||||||
return self
|
return self
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __len__(self):
|
def __len__(self) -> int:
|
||||||
"""Get a rough estimate of the number of games produced by the source"""
|
"""Get a rough estimate of the number of games produced by the source"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __next__(self):
|
def __next__(self) -> "Game" | None:
|
||||||
"""Get the next generated game from the source.
|
"""Get the next generated game from the source.
|
||||||
Raises StopIteration when exhausted.
|
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):
|
class Source(Iterable):
|
||||||
"""Source of games. E.g an installed app with a config file that lists game directories"""
|
"""Source of games. E.g an installed app with a config file that lists game directories"""
|
||||||
|
|
||||||
win = None
|
win: "CartridgesWindow" = None
|
||||||
|
|
||||||
name: str
|
name: str
|
||||||
variant: str
|
variant: str
|
||||||
|
|
||||||
@@ -38,7 +41,7 @@ class Source(Iterable):
|
|||||||
self.win = win
|
self.win = win
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def full_name(self):
|
def full_name(self) -> str:
|
||||||
"""The source's full name"""
|
"""The source's full name"""
|
||||||
full_name_ = self.name
|
full_name_ = self.name
|
||||||
if self.variant is not None:
|
if self.variant is not None:
|
||||||
@@ -46,7 +49,7 @@ class Source(Iterable):
|
|||||||
return full_name_
|
return full_name_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def id(self): # pylint: disable=invalid-name
|
def id(self) -> str: # pylint: disable=invalid-name
|
||||||
"""The source's identifier"""
|
"""The source's identifier"""
|
||||||
id_ = self.name.lower()
|
id_ = self.name.lower()
|
||||||
if self.variant is not None:
|
if self.variant is not None:
|
||||||
@@ -54,7 +57,7 @@ class Source(Iterable):
|
|||||||
return id_
|
return id_
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def game_id_format(self):
|
def game_id_format(self) -> str:
|
||||||
"""The string format used to construct game IDs"""
|
"""The string format used to construct game IDs"""
|
||||||
format_ = self.name.lower()
|
format_ = self.name.lower()
|
||||||
if self.variant is not None:
|
if self.variant is not None:
|
||||||
@@ -64,14 +67,14 @@ class Source(Iterable):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def executable_format(self):
|
def executable_format(self) -> str:
|
||||||
"""The executable format used to construct game executables"""
|
"""The executable format used to construct game executables"""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def is_installed(self):
|
def is_installed(self) -> bool:
|
||||||
"""Whether the source is detected as installed"""
|
"""Whether the source is detected as installed"""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def __iter__(self):
|
def __iter__(self) -> SourceIterator:
|
||||||
"""Get the source's iterator, to use in for loops"""
|
"""Get the source's iterator, to use in for loops"""
|
||||||
|
|||||||
180
src/importer/sources/steam_source.py
Normal file
180
src/importer/sources/steam_source.py
Normal 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()
|
||||||
@@ -36,6 +36,11 @@ from src.importer.sources.lutris_source import (
|
|||||||
LutrisFlatpakSource,
|
LutrisFlatpakSource,
|
||||||
LutrisNativeSource,
|
LutrisNativeSource,
|
||||||
)
|
)
|
||||||
|
from src.importer.sources.steam_source import (
|
||||||
|
SteamNativeSource,
|
||||||
|
SteamFlatpakSource,
|
||||||
|
SteamWindowsSource,
|
||||||
|
)
|
||||||
from src.preferences import PreferencesWindow
|
from src.preferences import PreferencesWindow
|
||||||
from src.window import CartridgesWindow
|
from src.window import CartridgesWindow
|
||||||
|
|
||||||
@@ -156,6 +161,10 @@ class CartridgesApplication(Adw.Application):
|
|||||||
if self.win.schema.get_boolean("lutris"):
|
if self.win.schema.get_boolean("lutris"):
|
||||||
importer.add_source(LutrisNativeSource(self.win))
|
importer.add_source(LutrisNativeSource(self.win))
|
||||||
importer.add_source(LutrisFlatpakSource(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()
|
importer.run()
|
||||||
|
|
||||||
def on_remove_game_action(self, *_args):
|
def on_remove_game_action(self, *_args):
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class MyClass():
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from os import PathLike
|
from os import PathLike, environ
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
|
||||||
|
|
||||||
@@ -52,3 +52,21 @@ def replaced_by_schema_key(key: str): # Decorator builder
|
|||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
return decorator
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user