🎨 Various changes

- Changed source additional data to dict
- Moved local cover saving into a manager
- Added stub for itch cover manager
This commit is contained in:
GeoffreyCoulaud
2023-06-07 15:00:42 +02:00
parent 98f02da36c
commit 5dc6ec899a
18 changed files with 106 additions and 54 deletions

View File

@@ -120,7 +120,7 @@ class Importer:
# Handle the result depending on its type # Handle the result depending on its type
if isinstance(iteration_result, Game): if isinstance(iteration_result, Game):
game = iteration_result game = iteration_result
additional_data = tuple() additional_data = {}
elif isinstance(iteration_result, tuple): elif isinstance(iteration_result, tuple):
game, additional_data = iteration_result game, additional_data = iteration_result
elif iteration_result is None: elif iteration_result is None:

View File

@@ -1,20 +1,23 @@
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Optional, Generator
import yaml import yaml
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.source import LinuxSource, Source, SourceIterator from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
)
from src.utils.decorators import replaced_by_env_path, replaced_by_path from src.utils.decorators import replaced_by_env_path, replaced_by_path
from src.utils.save_cover import resize_cover, save_cover
class BottlesSourceIterator(SourceIterator): class BottlesSourceIterator(SourceIterator):
source: "BottlesSource" source: "BottlesSource"
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games""" """Generator method producing games"""
data = (self.source.location / "library.yml").read_text("utf-8") data = (self.source.location / "library.yml").read_text("utf-8")
@@ -34,17 +37,16 @@ class BottlesSourceIterator(SourceIterator):
} }
game = Game(values, allow_side_effects=False) game = Game(values, allow_side_effects=False)
# Save official cover # Get official cover path
bottle_path = entry["bottle"]["path"] bottle_path = entry["bottle"]["path"]
image_name = entry["thumbnail"].split(":")[1] image_name = entry["thumbnail"].split(":")[1]
image_path = ( image_path = (
self.source.location / "bottles" / bottle_path / "grids" / image_name self.source.location / "bottles" / bottle_path / "grids" / image_name
) )
if image_path.is_file(): additional_data = {"local_image_path": image_path}
save_cover(values["game_id"], resize_cover(image_path))
# Produce game # Produce game
yield game yield (game, additional_data)
class BottlesSource(Source): class BottlesSource(Source):

View File

@@ -4,18 +4,18 @@ from hashlib import sha256
from json import JSONDecodeError from json import JSONDecodeError
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Optional, TypedDict, Generator from typing import Optional, TypedDict
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.source import ( from src.importer.sources.source import (
LinuxSource, LinuxSource,
Source, Source,
SourceIterationResult,
SourceIterator, SourceIterator,
WindowsSource, 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
from src.utils.save_cover import resize_cover, save_cover
class HeroicLibraryEntry(TypedDict): class HeroicLibraryEntry(TypedDict):
@@ -50,7 +50,9 @@ class HeroicSourceIterator(SourceIterator):
}, },
} }
def game_from_library_entry(self, entry: HeroicLibraryEntry) -> Optional[Game]: def game_from_library_entry(
self, entry: HeroicLibraryEntry
) -> SourceIterationResult:
"""Helper method used to build a Game from a Heroic library entry""" """Helper method used to build a Game from a Heroic library entry"""
# Skip games that are not installed # Skip games that are not installed
@@ -72,20 +74,20 @@ class HeroicSourceIterator(SourceIterator):
), ),
"executable": self.source.executable_format.format(app_name=app_name), "executable": self.source.executable_format.format(app_name=app_name),
} }
game = Game(values, allow_side_effects=False)
# Save image from the heroic cache # Get the image path from the heroic cache
# Filenames are derived from the URL that heroic used to get the file # Filenames are derived from the URL that heroic used to get the file
uri: str = entry["art_square"] uri: str = entry["art_square"]
if service == "epic": if service == "epic":
uri += "?h=400&resize=1&w=300" uri += "?h=400&resize=1&w=300"
digest = sha256(uri.encode()).hexdigest() digest = sha256(uri.encode()).hexdigest()
image_path = self.source.location / "images-cache" / digest image_path = self.source.location / "images-cache" / digest
if image_path.is_file(): additional_data = {"local_image_path": image_path}
save_cover(values["game_id"], resize_cover(image_path))
return Game(values, allow_side_effects=False) return (game, additional_data)
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games from all the Heroic sub-sources""" """Generator method producing games from all the Heroic sub-sources"""
for sub_source in self.sub_sources.values(): for sub_source in self.sub_sources.values():
@@ -102,12 +104,12 @@ class HeroicSourceIterator(SourceIterator):
continue continue
for entry in library: for entry in library:
try: try:
game = self.game_from_library_entry(entry) result = self.game_from_library_entry(entry)
except KeyError: except KeyError:
# Skip invalid games # Skip invalid games
logging.warning("Invalid Heroic game skipped in %s", str(file)) logging.warning("Invalid Heroic game skipped in %s", str(file))
continue continue
yield game yield result
class HeroicSource(Source): class HeroicSource(Source):

View File

@@ -1,23 +1,23 @@
from sqlite3 import connect
from pathlib import Path from pathlib import Path
from sqlite3 import connect
from time import time from time import time
from typing import Optional, Generator
from src import shared from src import shared
from src.utils.decorators import replaced_by_env_path, replaced_by_path
from src.game import Game from src.game import Game
from src.importer.sources.source import ( from src.importer.sources.source import (
Source,
SourceIterator,
LinuxSource, LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
WindowsSource, WindowsSource,
) )
from src.utils.decorators import replaced_by_env_path, replaced_by_path
class ItchSourceIterator(SourceIterator): class ItchSourceIterator(SourceIterator):
source: "ItchSource" source: "ItchSource"
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games""" """Generator method producing games"""
# Query the database # Query the database
@@ -48,7 +48,7 @@ class ItchSourceIterator(SourceIterator):
"game_id": self.source.game_id_format.format(game_id=row[0]), "game_id": self.source.game_id_format.format(game_id=row[0]),
"executable": self.source.executable_format.format(cave_id=row[4]), "executable": self.source.executable_format.format(cave_id=row[4]),
} }
additional_data = (row[3], row[2]) additional_data = {"itch_cover_url": row[2], "itch_still_cover_url": row[3]}
game = Game(values, allow_side_effects=False) game = Game(values, allow_side_effects=False)
yield (game, additional_data) yield (game, additional_data)

View File

@@ -1,18 +1,21 @@
from sqlite3 import connect from sqlite3 import connect
from time import time from time import time
from typing import Optional, Generator
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.source import LinuxSource, Source, SourceIterator from src.importer.sources.source import (
LinuxSource,
Source,
SourceIterationResult,
SourceIterator,
)
from src.utils.decorators import replaced_by_path from src.utils.decorators import replaced_by_path
from src.utils.save_cover import resize_cover, save_cover
class LutrisSourceIterator(SourceIterator): class LutrisSourceIterator(SourceIterator):
source: "LutrisSource" source: "LutrisSource"
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games""" """Generator method producing games"""
# Query the database # Query the database
@@ -48,13 +51,12 @@ class LutrisSourceIterator(SourceIterator):
} }
game = Game(values, allow_side_effects=False) game = Game(values, allow_side_effects=False)
# Save official image # Get official image path
image_path = self.source.location / "covers" / "coverart" / f"{row[2]}.jpg" image_path = self.source.location / "covers" / "coverart" / f"{row[2]}.jpg"
if image_path.exists(): additional_data = {"local_image_path": image_path}
save_cover(values["game_id"], resize_cover(image_path))
# Produce game # Produce game
yield game yield (game, additional_data)
class LutrisSource(Source): class LutrisSource(Source):

View File

@@ -3,7 +3,7 @@ from abc import abstractmethod
from collections.abc import Iterable, Iterator from collections.abc import Iterable, Iterator
from functools import wraps from functools import wraps
from pathlib import Path from pathlib import Path
from typing import Generator, Any from typing import Generator, Any, TypedDict
from src import shared from src import shared
from src.game import Game from src.game import Game

View File

@@ -1,18 +1,18 @@
import re import re
from pathlib import Path from pathlib import Path
from time import time from time import time
from typing import Iterable, Optional, Generator from typing import Iterable
from src import shared from src import shared
from src.game import Game from src.game import Game
from src.importer.sources.source import ( from src.importer.sources.source import (
LinuxSource, LinuxSource,
Source, Source,
SourceIterationResult,
SourceIterator, SourceIterator,
WindowsSource, 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
from src.utils.save_cover import resize_cover, save_cover
from src.utils.steam import SteamHelper, SteamInvalidManifestError from src.utils.steam import SteamHelper, SteamInvalidManifestError
@@ -44,7 +44,7 @@ class SteamSourceIterator(SourceIterator):
) )
return manifests return manifests
def generator_builder(self) -> Generator[Optional[Game], None, None]: def generator_builder(self) -> SourceIterationResult:
"""Generator method producing games""" """Generator method producing games"""
appid_cache = set() appid_cache = set()
manifests = self.get_manifests() manifests = self.get_manifests()
@@ -85,11 +85,10 @@ class SteamSourceIterator(SourceIterator):
/ "librarycache" / "librarycache"
/ f"{appid}_library_600x900.jpg" / f"{appid}_library_600x900.jpg"
) )
if image_path.is_file(): additional_data = {"local_image_path": image_path}
save_cover(game.game_id, resize_cover(image_path))
# Produce game # Produce game
yield game yield (game, additional_data)
class SteamSource(Source): class SteamSource(Source):

View File

@@ -43,6 +43,8 @@ from src.store.managers.display_manager import DisplayManager
from src.store.managers.file_manager import FileManager from src.store.managers.file_manager import FileManager
from src.store.managers.sgdb_manager import SGDBManager from src.store.managers.sgdb_manager import SGDBManager
from src.store.managers.steam_api_manager import SteamAPIManager from src.store.managers.steam_api_manager import SteamAPIManager
from src.store.managers.local_cover_manager import LocalCoverManager
from src.store.managers.itch_cover_manager import ItchCoverManager
from src.store.store import Store from src.store.store import Store
from src.window import CartridgesWindow from src.window import CartridgesWindow
@@ -85,7 +87,9 @@ class CartridgesApplication(Adw.Application):
self.load_games_from_disk() self.load_games_from_disk()
# Add rest of the managers for game imports # Add rest of the managers for game imports
shared.store.add_manager(LocalCoverManager())
shared.store.add_manager(SteamAPIManager()) shared.store.add_manager(SteamAPIManager())
shared.store.add_manager(ItchCoverManager())
shared.store.add_manager(SGDBManager()) shared.store.add_manager(SGDBManager())
shared.store.add_manager(FileManager()) shared.store.add_manager(FileManager())

View File

@@ -27,7 +27,7 @@ class AsyncManager(Manager):
self.cancellable = Gio.Cancellable() self.cancellable = Gio.Cancellable()
def process_game( def process_game(
self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any] self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None: ) -> None:
"""Create a task to process the game in a separate thread""" """Create a task to process the game in a separate thread"""
task = Task.new(None, self.cancellable, self._task_callback, (callback,)) task = Task.new(None, self.cancellable, self._task_callback, (callback,))

View File

@@ -10,7 +10,7 @@ class DisplayManager(Manager):
run_after = set((SteamAPIManager, SGDBManager)) run_after = set((SteamAPIManager, SGDBManager))
def manager_logic(self, game: Game, _additional_data: tuple) -> None: def manager_logic(self, game: Game, _additional_data: dict) -> None:
# TODO decouple a game from its widget # TODO decouple a game from its widget
shared.win.games[game.game_id] = game shared.win.games[game.game_id] = game
game.update() game.update()

View File

@@ -8,5 +8,5 @@ class FileManager(AsyncManager):
run_after = set((SteamAPIManager,)) run_after = set((SteamAPIManager,))
def manager_logic(self, game: Game, _additional_data: tuple) -> None: def manager_logic(self, game: Game, _additional_data: dict) -> None:
game.save() game.save()

View File

@@ -0,0 +1,19 @@
from urllib3.exceptions import SSLError
import requests
from requests import HTTPError
from src.game import Game
from src.store.managers.async_manager import AsyncManager
from src.store.managers.local_cover_manager import LocalCoverManager
class ItchCoverManager(AsyncManager):
"""Manager in charge of downloading the game's cover from itch.io"""
run_after = set((LocalCoverManager,))
retryable_on = set((HTTPError, SSLError))
def manager_logic(self, game: Game, additional_data: dict) -> None:
# TODO move itch cover logic here
pass

View File

@@ -0,0 +1,23 @@
from pathlib import Path
from src.game import Game
from src.store.managers.manager import Manager
from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.save_cover import save_cover, resize_cover
class LocalCoverManager(Manager):
"""Manager in charge of adding the local cover image of the game"""
run_after = set((SteamAPIManager,))
def manager_logic(self, game: Game, additional_data: dict) -> None:
# Ensure that the cover path is in the additional data
try:
image_path: Path = additional_data["local_image_path"]
except KeyError:
return
if not image_path.is_file():
return
# Save the image
save_cover(game.game_id, resize_cover(image_path))

View File

@@ -49,7 +49,7 @@ class Manager:
return errors return errors
@abstractmethod @abstractmethod
def manager_logic(self, game: Game, additional_data: tuple) -> None: def manager_logic(self, game: Game, additional_data: dict) -> None:
""" """
Manager specific logic triggered by the run method Manager specific logic triggered by the run method
* Implemented by final child classes * Implemented by final child classes
@@ -59,7 +59,7 @@ class Manager:
""" """
def execute_resilient_manager_logic( def execute_resilient_manager_logic(
self, game: Game, additional_data: tuple, try_index: int = 0 self, game: Game, additional_data: dict, try_index: int = 0
) -> None: ) -> None:
"""Execute the manager logic and handle its errors by reporting them or retrying""" """Execute the manager logic and handle its errors by reporting them or retrying"""
try: try:
@@ -93,7 +93,7 @@ class Manager:
) )
def process_game( def process_game(
self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any] self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any]
) -> None: ) -> None:
"""Pass the game through the manager""" """Pass the game through the manager"""
self.execute_resilient_manager_logic(game, additional_data) self.execute_resilient_manager_logic(game, additional_data)

View File

@@ -2,6 +2,8 @@ from urllib3.exceptions import SSLError
from src.game import Game from src.game import Game
from src.store.managers.async_manager import AsyncManager from src.store.managers.async_manager import AsyncManager
from src.store.managers.itch_cover_manager import ItchCoverManager
from src.store.managers.local_cover_manager import LocalCoverManager
from src.store.managers.steam_api_manager import SteamAPIManager from src.store.managers.steam_api_manager import SteamAPIManager
from src.utils.steamgriddb import HTTPError, SGDBAuthError, SGDBHelper from src.utils.steamgriddb import HTTPError, SGDBAuthError, SGDBHelper
@@ -9,10 +11,10 @@ from src.utils.steamgriddb import HTTPError, SGDBAuthError, SGDBHelper
class SGDBManager(AsyncManager): class SGDBManager(AsyncManager):
"""Manager in charge of downloading a game's cover from steamgriddb""" """Manager in charge of downloading a game's cover from steamgriddb"""
run_after = set((SteamAPIManager,)) run_after = set((SteamAPIManager, LocalCoverManager, ItchCoverManager))
retryable_on = set((HTTPError, SSLError)) retryable_on = set((HTTPError, SSLError))
def manager_logic(self, game: Game, _additional_data: tuple) -> None: def manager_logic(self, game: Game, _additional_data: dict) -> None:
try: try:
sgdb = SGDBHelper() sgdb = SGDBHelper()
sgdb.conditionaly_update_cover(game) sgdb.conditionaly_update_cover(game)

View File

@@ -15,11 +15,10 @@ class SteamAPIManager(AsyncManager):
retryable_on = set((HTTPError, SSLError)) retryable_on = set((HTTPError, SSLError))
def manager_logic(self, game: Game, _additional_data: tuple) -> None: def manager_logic(self, game: Game, _additional_data: dict) -> None:
# Skip non-steam games # Skip non-steam games
if not game.source.startswith("steam_"): if not game.source.startswith("steam_"):
return return
# Get online metadata # Get online metadata
appid = str(game.game_id).split("_")[-1] appid = str(game.game_id).split("_")[-1]
steam = SteamHelper() steam = SteamHelper()

View File

@@ -11,14 +11,14 @@ class Pipeline(GObject.Object):
"""Class representing a set of managers for a game""" """Class representing a set of managers for a game"""
game: Game game: Game
additional_data: tuple additional_data: dict
waiting: set[Manager] waiting: set[Manager]
running: set[Manager] running: set[Manager]
done: set[Manager] done: set[Manager]
def __init__( def __init__(
self, game: Game, additional_data: tuple, managers: Iterable[Manager] self, game: Game, additional_data: dict, managers: Iterable[Manager]
) -> None: ) -> None:
super().__init__() super().__init__()
self.game = game self.game = game

View File

@@ -21,7 +21,7 @@ class Store:
self.managers.add(manager) self.managers.add(manager)
def add_game( def add_game(
self, game: Game, additional_data: tuple, replace=False self, game: Game, additional_data: dict, replace=False
) -> Pipeline | None: ) -> Pipeline | None:
"""Add a game to the app if not already there """Add a game to the app if not already there