🚧 Ground work for heroic source
This commit is contained in:
176
src/importer/sources/heroic_source.py
Normal file
176
src/importer/sources/heroic_source.py
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
import json
|
||||||
|
from abc import abstractmethod
|
||||||
|
from hashlib import sha256
|
||||||
|
from json import JSONDecodeError
|
||||||
|
from pathlib import Path
|
||||||
|
from time import time
|
||||||
|
from typing import Generator, Optional, TypedDict
|
||||||
|
|
||||||
|
from src import shared
|
||||||
|
from src.game import Game
|
||||||
|
from src.importer.sources.source import Source, SourceIterator
|
||||||
|
from src.utils.decorators import (
|
||||||
|
replaced_by_env_path,
|
||||||
|
replaced_by_path,
|
||||||
|
replaced_by_schema_key,
|
||||||
|
)
|
||||||
|
from src.utils.save_cover import resize_cover, save_cover
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicLibraryEntry(TypedDict):
|
||||||
|
app_name: str
|
||||||
|
installed: Optional[bool]
|
||||||
|
runner: str
|
||||||
|
title: str
|
||||||
|
developer: str
|
||||||
|
art_square: str
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicSubSource(TypedDict):
|
||||||
|
service: str
|
||||||
|
path: tuple[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicSourceIterator(SourceIterator):
|
||||||
|
source: "HeroicSource"
|
||||||
|
generator: Generator = None
|
||||||
|
sub_sources: dict[str, HeroicSubSource] = {
|
||||||
|
"sideload": {
|
||||||
|
"service": "sideload",
|
||||||
|
"path": ("sideload_apps", "library.json"),
|
||||||
|
},
|
||||||
|
"legendary": {
|
||||||
|
"service": "epic",
|
||||||
|
"path": ("store_cache", "legendary_library.json"),
|
||||||
|
},
|
||||||
|
"gog": {
|
||||||
|
"service": "gog",
|
||||||
|
"path": ("store_cache", "gog_library.json"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def game_from_library_entry(self, entry: HeroicLibraryEntry) -> Optional[Game]:
|
||||||
|
"""Helper method used to build a Game from a Heroic library entry"""
|
||||||
|
|
||||||
|
# Skip games that are not installed
|
||||||
|
if not entry["installed"]:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Build game
|
||||||
|
app_name = entry["app_name"]
|
||||||
|
runner = entry["runner"]
|
||||||
|
service = self.sub_sources[runner]["service"]
|
||||||
|
values = {
|
||||||
|
"version": shared.SPEC_VERSION,
|
||||||
|
"hidden": False,
|
||||||
|
"source": self.source.id,
|
||||||
|
"added": int(time()),
|
||||||
|
"name": entry["title"],
|
||||||
|
"developer": entry["developer"],
|
||||||
|
"game_id": self.source.game_id_format.format(
|
||||||
|
service=service, game_id=app_name
|
||||||
|
),
|
||||||
|
"executable": self.source.executable_format.format(app_name=app_name),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Save image from the heroic cache
|
||||||
|
# Filenames are derived from the URL that heroic used to get the file
|
||||||
|
uri: str = entry["art_square"]
|
||||||
|
if service == "epic":
|
||||||
|
uri += "?h=400&resize=1&w=300"
|
||||||
|
digest = sha256(uri.encode()).hexdigest()
|
||||||
|
image_path = self.source.location / "images-cache" / digest
|
||||||
|
if image_path.is_file():
|
||||||
|
save_cover(values["game_id"], resize_cover(image_path))
|
||||||
|
|
||||||
|
return Game(values, allow_side_effects=False)
|
||||||
|
|
||||||
|
def sub_sources_generator(self):
|
||||||
|
"""Generator method producing games from all the Heroic sub-sources"""
|
||||||
|
for key, sub_source in self.sub_sources.items():
|
||||||
|
# Skip disabled sub-sources
|
||||||
|
if not shared.schema.get_boolean("heroic-import-" + key):
|
||||||
|
continue
|
||||||
|
# Load games from JSON
|
||||||
|
try:
|
||||||
|
file = self.source.location.joinpath(*sub_source["path"])
|
||||||
|
library = json.load(file.open())["library"]
|
||||||
|
except (JSONDecodeError, OSError, KeyError):
|
||||||
|
# Invalid library.json file, skip it
|
||||||
|
continue
|
||||||
|
for entry in library:
|
||||||
|
try:
|
||||||
|
game = self.game_from_library_entry(entry)
|
||||||
|
except KeyError:
|
||||||
|
# Skip invalid games
|
||||||
|
continue
|
||||||
|
yield game
|
||||||
|
|
||||||
|
def __init__(self, source: "HeroicSource") -> None:
|
||||||
|
self.source = source
|
||||||
|
self.generator = self.sub_sources_generator()
|
||||||
|
|
||||||
|
def __next__(self) -> Optional[Game]:
|
||||||
|
try:
|
||||||
|
game = next(self.generator)
|
||||||
|
except StopIteration:
|
||||||
|
raise
|
||||||
|
return game
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicSource(Source):
|
||||||
|
"""Generic heroic games launcher source"""
|
||||||
|
|
||||||
|
name = "Heroic"
|
||||||
|
executable_format = "xdg-open heroic://launch/{app_name}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
@abstractmethod
|
||||||
|
def location(self) -> Path:
|
||||||
|
pass
|
||||||
|
|
||||||
|
@property
|
||||||
|
def game_id_format(self) -> str:
|
||||||
|
"""The string format used to construct game IDs"""
|
||||||
|
return self.name.lower() + "_{service}_{game_id}"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_installed(self):
|
||||||
|
# pylint: disable=pointless-statement
|
||||||
|
try:
|
||||||
|
self.location
|
||||||
|
except FileNotFoundError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return HeroicSourceIterator(source=self)
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicNativeSource(HeroicSource):
|
||||||
|
variant = "native"
|
||||||
|
|
||||||
|
@replaced_by_schema_key("heroic-location")
|
||||||
|
@replaced_by_env_path("XDG_CONFIG_HOME", "heroic/")
|
||||||
|
@replaced_by_path("~/.config/heroic/")
|
||||||
|
def location(self) -> Path:
|
||||||
|
raise FileNotFoundError()
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicFlatpakSource(HeroicSource):
|
||||||
|
variant = "flatpak"
|
||||||
|
|
||||||
|
@replaced_by_schema_key("heroic-flatpak-location")
|
||||||
|
@replaced_by_path("~/.var/app/com.heroicgameslauncher.hgl/config/heroic/")
|
||||||
|
def location(self) -> Path:
|
||||||
|
raise FileNotFoundError()
|
||||||
|
|
||||||
|
|
||||||
|
class HeroicWindowsSource(HeroicSource):
|
||||||
|
variant = "windows"
|
||||||
|
executable_format = "start heroic://launch/{app_name}"
|
||||||
|
|
||||||
|
@replaced_by_schema_key("heroic-windows-location")
|
||||||
|
@replaced_by_env_path("appdata", "heroic/")
|
||||||
|
def location(self) -> Path:
|
||||||
|
raise FileNotFoundError()
|
||||||
@@ -34,6 +34,11 @@ from src import shared
|
|||||||
from src.details_window import DetailsWindow
|
from src.details_window import DetailsWindow
|
||||||
from src.game import Game
|
from src.game import Game
|
||||||
from src.importer.importer import Importer
|
from src.importer.importer import Importer
|
||||||
|
from src.importer.sources.heroic_source import (
|
||||||
|
HeroicFlatpakSource,
|
||||||
|
HeroicNativeSource,
|
||||||
|
HeroicWindowsSource,
|
||||||
|
)
|
||||||
from src.importer.sources.lutris_source import LutrisFlatpakSource, LutrisNativeSource
|
from src.importer.sources.lutris_source import LutrisFlatpakSource, LutrisNativeSource
|
||||||
from src.importer.sources.steam_source import (
|
from src.importer.sources.steam_source import (
|
||||||
SteamFlatpakSource,
|
SteamFlatpakSource,
|
||||||
@@ -193,6 +198,10 @@ class CartridgesApplication(Adw.Application):
|
|||||||
importer.add_source(SteamNativeSource())
|
importer.add_source(SteamNativeSource())
|
||||||
importer.add_source(SteamFlatpakSource())
|
importer.add_source(SteamFlatpakSource())
|
||||||
importer.add_source(SteamWindowsSource())
|
importer.add_source(SteamWindowsSource())
|
||||||
|
if shared.schema.get_boolean("heroic"):
|
||||||
|
importer.add_source(HeroicNativeSource())
|
||||||
|
importer.add_source(HeroicFlatpakSource())
|
||||||
|
importer.add_source(HeroicWindowsSource())
|
||||||
importer.run()
|
importer.run()
|
||||||
|
|
||||||
def on_remove_game_action(self, *_args):
|
def on_remove_game_action(self, *_args):
|
||||||
|
|||||||
Reference in New Issue
Block a user