🎨 Various code style / behaviour fixes
- Merged platform sources when possible - Added URLExecutableSource class - Moved replaced_by_schema_key to utils/decorators - Better retryable exception handling in some managers - Split SteamHelper into SteamFileHelper and SteamAPIHelper - Delegated SteamRateLimiter creation to SteamAPIManager init - Using additional_data for appid in SteamAPIManager - Added Windows support for Legendary - Stylistic changed suggested by pylint
This commit is contained in:
@@ -6,12 +6,15 @@ import yaml
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import (
|
||||
replaced_by_env_path,
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_env_path, replaced_by_path
|
||||
|
||||
|
||||
class BottlesSourceIterator(SourceIterator):
|
||||
@@ -49,22 +52,16 @@ class BottlesSourceIterator(SourceIterator):
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class BottlesSource(Source):
|
||||
class BottlesSource(URLExecutableSource):
|
||||
"""Generic Bottles source"""
|
||||
|
||||
name = "Bottles"
|
||||
location_key = "bottles-location"
|
||||
|
||||
def __iter__(self) -> SourceIterator:
|
||||
return BottlesSourceIterator(self)
|
||||
|
||||
|
||||
class BottlesLinuxSource(BottlesSource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = 'xdg-open bottles:run/"{bottle_name}"/"{game_name}"'
|
||||
iterator_class = BottlesSourceIterator
|
||||
url_format = 'bottles:run/"{bottle_name}"/"{game_name}"'
|
||||
available_on = set(("linux",))
|
||||
|
||||
@property
|
||||
@BottlesSource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.usebottles.bottles/data/bottles/")
|
||||
@replaced_by_env_path("XDG_DATA_HOME", "bottles/")
|
||||
@replaced_by_path("~/.local/share/bottles/")
|
||||
|
||||
@@ -9,13 +9,15 @@ from typing import Optional, TypedDict
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
URLExecutableSource,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
WindowsSource,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_env_path, replaced_by_path
|
||||
from src.utils.decorators import (
|
||||
replaced_by_env_path,
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
|
||||
|
||||
class HeroicLibraryEntry(TypedDict):
|
||||
@@ -112,40 +114,24 @@ class HeroicSourceIterator(SourceIterator):
|
||||
yield result
|
||||
|
||||
|
||||
class HeroicSource(Source):
|
||||
class HeroicSource(URLExecutableSource):
|
||||
"""Generic heroic games launcher source"""
|
||||
|
||||
name = "Heroic"
|
||||
location_key = "heroic-location"
|
||||
iterator_class = HeroicSourceIterator
|
||||
url_format = "heroic://launch/{app_name}"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
def game_id_format(self) -> str:
|
||||
"""The string format used to construct game IDs"""
|
||||
return self.name.lower() + "_{service}_{game_id}"
|
||||
|
||||
def __iter__(self):
|
||||
return HeroicSourceIterator(source=self)
|
||||
|
||||
|
||||
class HeroicLinuxSource(HeroicSource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = "xdg-open heroic://launch/{app_name}"
|
||||
|
||||
@property
|
||||
@HeroicSource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/")
|
||||
@replaced_by_env_path("XDG_CONFIG_HOME", "heroic/")
|
||||
@replaced_by_path("~/.config/heroic/")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
|
||||
class HeroicWindowsSource(HeroicSource, WindowsSource):
|
||||
variant = "windows"
|
||||
executable_format = "start heroic://launch/{app_name}"
|
||||
|
||||
@property
|
||||
@HeroicSource.replaced_by_schema_key()
|
||||
@replaced_by_env_path("appdata", "heroic/")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
@@ -5,13 +5,15 @@ from time import time
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
WindowsSource,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import (
|
||||
replaced_by_env_path,
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_env_path, replaced_by_path
|
||||
|
||||
|
||||
class ItchSourceIterator(SourceIterator):
|
||||
@@ -54,33 +56,17 @@ class ItchSourceIterator(SourceIterator):
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class ItchSource(Source):
|
||||
class ItchSource(URLExecutableSource):
|
||||
name = "Itch"
|
||||
location_key = "itch-location"
|
||||
|
||||
def __iter__(self) -> SourceIterator:
|
||||
return ItchSourceIterator(self)
|
||||
|
||||
|
||||
class ItchLinuxSource(ItchSource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = "xdg-open itch://caves/{cave_id}/launch"
|
||||
iterator_class = ItchSourceIterator
|
||||
url_format = "itch://caves/{cave_id}/launch"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@ItchSource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/io.itch.itch/config/itch/")
|
||||
@replaced_by_env_path("XDG_DATA_HOME", "itch/")
|
||||
@replaced_by_path("~/.config/itch")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
|
||||
class ItchWindowsSource(ItchSource, WindowsSource):
|
||||
variant = "windows"
|
||||
executable_format = "start itch://caves/{cave_id}/launch"
|
||||
|
||||
@property
|
||||
@ItchSource.replaced_by_schema_key()
|
||||
@replaced_by_env_path("appdata", "itch/")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Generator
|
||||
import json
|
||||
import logging
|
||||
from json import JSONDecodeError
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import Generator
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
from src.importer.sources.source import Source, SourceIterationResult, SourceIterator
|
||||
from src.utils.decorators import (
|
||||
replaced_by_env_path,
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_env_path, replaced_by_path
|
||||
|
||||
|
||||
class LegendarySourceIterator(SourceIterator):
|
||||
@@ -74,22 +73,14 @@ class LegendarySourceIterator(SourceIterator):
|
||||
|
||||
class LegendarySource(Source):
|
||||
name = "Legendary"
|
||||
location_key = "legendary-location"
|
||||
|
||||
def __iter__(self) -> SourceIterator:
|
||||
return LegendarySourceIterator(self)
|
||||
|
||||
|
||||
# TODO add Legendary windows variant
|
||||
|
||||
|
||||
class LegendaryLinuxSource(LegendarySource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = "legendary launch {app_name}"
|
||||
iterator_class = LegendarySourceIterator
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@LegendarySource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_env_path("XDG_CONFIG_HOME", "legendary/")
|
||||
@replaced_by_path("~/.config/legendary/")
|
||||
@replaced_by_path("~\\.config\\legendary\\")
|
||||
def location(self) -> Path:
|
||||
raise FileNotFoundError()
|
||||
|
||||
@@ -4,12 +4,11 @@ from time import time
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_path
|
||||
from src.utils.decorators import replaced_by_path, replaced_by_schema_key
|
||||
|
||||
|
||||
class LutrisSourceIterator(SourceIterator):
|
||||
@@ -47,7 +46,6 @@ class LutrisSourceIterator(SourceIterator):
|
||||
game_id=row[2], game_internal_id=row[0]
|
||||
),
|
||||
"executable": self.source.executable_format.format(game_id=row[2]),
|
||||
"developer": None, # TODO get developer metadata on Lutris
|
||||
}
|
||||
game = Game(values, allow_side_effects=False)
|
||||
|
||||
@@ -59,26 +57,20 @@ class LutrisSourceIterator(SourceIterator):
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class LutrisSource(Source):
|
||||
class LutrisSource(URLExecutableSource):
|
||||
"""Generic lutris source"""
|
||||
|
||||
name = "Lutris"
|
||||
location_key = "lutris-location"
|
||||
iterator_class = LutrisSourceIterator
|
||||
url_format = "lutris:rungameid/{game_id}"
|
||||
available_on = set(("linux",))
|
||||
|
||||
@property
|
||||
def game_id_format(self):
|
||||
return super().game_id_format + "_{game_internal_id}"
|
||||
|
||||
def __iter__(self):
|
||||
return LutrisSourceIterator(source=self)
|
||||
|
||||
|
||||
class LutrisLinuxSource(LutrisSource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = "xdg-open lutris:rungameid/{game_id}"
|
||||
|
||||
@property
|
||||
@LutrisSource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/net.lutris.Lutris/data/lutris/")
|
||||
@replaced_by_path("~/.local/share/lutris/")
|
||||
def location(self):
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import sys
|
||||
from abc import abstractmethod
|
||||
from collections.abc import Iterable, Iterator
|
||||
from functools import wraps
|
||||
from pathlib import Path
|
||||
from typing import Generator, Any, TypedDict
|
||||
from typing import Generator, Any
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.utils.decorators import replaced_by_path
|
||||
|
||||
# Type of the data returned by iterating on a Source
|
||||
SourceIterationResult = None | Game | tuple[Game, tuple[Any]]
|
||||
@@ -45,13 +43,9 @@ class Source(Iterable):
|
||||
"""Source of games. E.g an installed app with a config file that lists game directories"""
|
||||
|
||||
name: str
|
||||
variant: str
|
||||
location_key: str
|
||||
available_on: set[str]
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.available_on = set()
|
||||
iterator_class: type[SourceIterator]
|
||||
variant: str = None
|
||||
available_on: set[str] = set()
|
||||
|
||||
@property
|
||||
def full_name(self) -> str:
|
||||
@@ -83,6 +77,14 @@ class Source(Iterable):
|
||||
return False
|
||||
return sys.platform in self.available_on
|
||||
|
||||
@property
|
||||
def location_key(self) -> str:
|
||||
"""
|
||||
The schema key pointing to the user-set location for the source.
|
||||
May be overriden by inherinting classes.
|
||||
"""
|
||||
return f"{self.name.lower()}-location"
|
||||
|
||||
def update_location_schema_key(self):
|
||||
"""Update the schema value for this source's location if possible"""
|
||||
try:
|
||||
@@ -91,19 +93,9 @@ class Source(Iterable):
|
||||
return
|
||||
shared.schema.set_string(self.location_key, location)
|
||||
|
||||
@classmethod
|
||||
def replaced_by_schema_key(cls): # Decorator builder
|
||||
"""Replace the returned path with schema's path if valid"""
|
||||
|
||||
def decorator(original_function): # Built decorator (closure)
|
||||
@wraps(original_function)
|
||||
def wrapper(*args, **kwargs): # func's override
|
||||
override = shared.schema.get_string(cls.location_key)
|
||||
return replaced_by_path(override)(original_function)(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
def __iter__(self) -> SourceIterator:
|
||||
"""Get an iterator for the source"""
|
||||
return self.iterator_class(self)
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
@@ -115,22 +107,21 @@ class Source(Iterable):
|
||||
def executable_format(self) -> str:
|
||||
"""The executable format used to construct game executables"""
|
||||
|
||||
@abstractmethod
|
||||
def __iter__(self) -> SourceIterator:
|
||||
"""Get the source's iterator, to use in for loops"""
|
||||
|
||||
# pylint: disable=abstract-method
|
||||
class URLExecutableSource(Source):
|
||||
"""Source class that use custom URLs to start games"""
|
||||
|
||||
class WindowsSource(Source):
|
||||
"""Mixin for sources available on Windows"""
|
||||
url_format: str
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.available_on.add("win32")
|
||||
|
||||
|
||||
class LinuxSource(Source):
|
||||
"""Mixin for sources available on Linux"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self.available_on.add("linux")
|
||||
@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}"
|
||||
)
|
||||
|
||||
@@ -6,14 +6,16 @@ from typing import Iterable
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.source import (
|
||||
LinuxSource,
|
||||
Source,
|
||||
SourceIterationResult,
|
||||
SourceIterator,
|
||||
WindowsSource,
|
||||
URLExecutableSource,
|
||||
)
|
||||
from src.utils.decorators import replaced_by_env_path, replaced_by_path
|
||||
from src.utils.steam import SteamHelper, SteamInvalidManifestError
|
||||
from src.utils.decorators import (
|
||||
replaced_by_env_path,
|
||||
replaced_by_path,
|
||||
replaced_by_schema_key,
|
||||
)
|
||||
from src.utils.steam import SteamFileHelper, SteamInvalidManifestError
|
||||
|
||||
|
||||
class SteamSourceIterator(SourceIterator):
|
||||
@@ -22,11 +24,11 @@ class SteamSourceIterator(SourceIterator):
|
||||
def get_manifest_dirs(self) -> Iterable[Path]:
|
||||
"""Get dirs that contain steam app manifests"""
|
||||
libraryfolders_path = self.source.location / "steamapps" / "libraryfolders.vdf"
|
||||
with open(libraryfolders_path, "r") as file:
|
||||
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)
|
||||
for path in re.findall('"path"\\s+"(.*)"\n', contents, re.IGNORECASE)
|
||||
]
|
||||
|
||||
def get_manifests(self) -> Iterable[Path]:
|
||||
@@ -50,15 +52,15 @@ class SteamSourceIterator(SourceIterator):
|
||||
manifests = self.get_manifests()
|
||||
for manifest in manifests:
|
||||
# Get metadata from manifest
|
||||
steam = SteamHelper()
|
||||
steam = SteamFileHelper()
|
||||
try:
|
||||
local_data = steam.get_manifest_data(manifest)
|
||||
except (OSError, SteamInvalidManifestError):
|
||||
continue
|
||||
|
||||
# Skip non installed games
|
||||
INSTALLED_MASK: int = 4
|
||||
if not int(local_data["stateflags"]) & INSTALLED_MASK:
|
||||
installed_mask = 4
|
||||
if not int(local_data["stateflags"]) & installed_mask:
|
||||
continue
|
||||
|
||||
# Skip duplicate appids
|
||||
@@ -85,40 +87,24 @@ class SteamSourceIterator(SourceIterator):
|
||||
/ "librarycache"
|
||||
/ f"{appid}_library_600x900.jpg"
|
||||
)
|
||||
additional_data = {"local_image_path": image_path}
|
||||
additional_data = {"local_image_path": image_path, "steam_appid": appid}
|
||||
|
||||
# Produce game
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class SteamSource(Source):
|
||||
class SteamSource(URLExecutableSource):
|
||||
name = "Steam"
|
||||
location_key = "steam-location"
|
||||
|
||||
def __iter__(self):
|
||||
return SteamSourceIterator(source=self)
|
||||
|
||||
|
||||
class SteamLinuxSource(SteamSource, LinuxSource):
|
||||
variant = "linux"
|
||||
executable_format = "xdg-open steam://rungameid/{game_id}"
|
||||
iterator_class = SteamSourceIterator
|
||||
url_format = "steam://rungameid/{game_id}"
|
||||
available_on = set(("linux", "win32"))
|
||||
|
||||
@property
|
||||
@SteamSource.replaced_by_schema_key()
|
||||
@replaced_by_schema_key
|
||||
@replaced_by_path("~/.var/app/com.valvesoftware.Steam/data/Steam/")
|
||||
@replaced_by_env_path("XDG_DATA_HOME", "Steam/")
|
||||
@replaced_by_path("~/.steam/")
|
||||
@replaced_by_path("~/.local/share/Steam/")
|
||||
def location(self):
|
||||
raise FileNotFoundError()
|
||||
|
||||
|
||||
class SteamWindowsSource(SteamSource, WindowsSource):
|
||||
variant = "windows"
|
||||
executable_format = "start steam://rungameid/{game_id}"
|
||||
|
||||
@property
|
||||
@SteamSource.replaced_by_schema_key()
|
||||
@replaced_by_env_path("programfiles(x86)", "Steam")
|
||||
def location(self):
|
||||
raise FileNotFoundError()
|
||||
|
||||
Reference in New Issue
Block a user