diff --git a/src/importer/importer.py b/src/importer/importer.py index bd463e1..fdbee81 100644 --- a/src/importer/importer.py +++ b/src/importer/importer.py @@ -120,7 +120,7 @@ class Importer: # Handle the result depending on its type if isinstance(iteration_result, Game): game = iteration_result - additional_data = tuple() + additional_data = {} elif isinstance(iteration_result, tuple): game, additional_data = iteration_result elif iteration_result is None: diff --git a/src/importer/sources/bottles_source.py b/src/importer/sources/bottles_source.py index 9fa1849..da70df6 100644 --- a/src/importer/sources/bottles_source.py +++ b/src/importer/sources/bottles_source.py @@ -1,20 +1,23 @@ from pathlib import Path from time import time -from typing import Optional, Generator import yaml from src import shared 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.save_cover import resize_cover, save_cover class BottlesSourceIterator(SourceIterator): source: "BottlesSource" - def generator_builder(self) -> Generator[Optional[Game], None, None]: + def generator_builder(self) -> SourceIterationResult: """Generator method producing games""" data = (self.source.location / "library.yml").read_text("utf-8") @@ -34,17 +37,16 @@ class BottlesSourceIterator(SourceIterator): } game = Game(values, allow_side_effects=False) - # Save official cover + # Get official cover path bottle_path = entry["bottle"]["path"] image_name = entry["thumbnail"].split(":")[1] image_path = ( self.source.location / "bottles" / bottle_path / "grids" / image_name ) - if image_path.is_file(): - save_cover(values["game_id"], resize_cover(image_path)) + additional_data = {"local_image_path": image_path} # Produce game - yield game + yield (game, additional_data) class BottlesSource(Source): diff --git a/src/importer/sources/heroic_source.py b/src/importer/sources/heroic_source.py index c6ef498..4d74ae7 100644 --- a/src/importer/sources/heroic_source.py +++ b/src/importer/sources/heroic_source.py @@ -4,18 +4,18 @@ from hashlib import sha256 from json import JSONDecodeError from pathlib import Path from time import time -from typing import Optional, TypedDict, Generator +from typing import Optional, TypedDict from src import shared from src.game import Game from src.importer.sources.source import ( LinuxSource, Source, + SourceIterationResult, SourceIterator, WindowsSource, ) 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): @@ -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""" # Skip games that are not installed @@ -72,20 +74,20 @@ class HeroicSourceIterator(SourceIterator): ), "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 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)) + additional_data = {"local_image_path": 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""" for sub_source in self.sub_sources.values(): @@ -102,12 +104,12 @@ class HeroicSourceIterator(SourceIterator): continue for entry in library: try: - game = self.game_from_library_entry(entry) + result = self.game_from_library_entry(entry) except KeyError: # Skip invalid games logging.warning("Invalid Heroic game skipped in %s", str(file)) continue - yield game + yield result class HeroicSource(Source): diff --git a/src/importer/sources/itch_source.py b/src/importer/sources/itch_source.py index eddbb2e..4ffda7b 100644 --- a/src/importer/sources/itch_source.py +++ b/src/importer/sources/itch_source.py @@ -1,23 +1,23 @@ -from sqlite3 import connect from pathlib import Path +from sqlite3 import connect from time import time -from typing import Optional, Generator from src import shared -from src.utils.decorators import replaced_by_env_path, replaced_by_path from src.game import Game from src.importer.sources.source import ( - Source, - SourceIterator, LinuxSource, + Source, + SourceIterationResult, + SourceIterator, WindowsSource, ) +from src.utils.decorators import replaced_by_env_path, replaced_by_path class ItchSourceIterator(SourceIterator): source: "ItchSource" - def generator_builder(self) -> Generator[Optional[Game], None, None]: + def generator_builder(self) -> SourceIterationResult: """Generator method producing games""" # Query the database @@ -48,7 +48,7 @@ class ItchSourceIterator(SourceIterator): "game_id": self.source.game_id_format.format(game_id=row[0]), "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) yield (game, additional_data) diff --git a/src/importer/sources/lutris_source.py b/src/importer/sources/lutris_source.py index 63e3344..641b75f 100644 --- a/src/importer/sources/lutris_source.py +++ b/src/importer/sources/lutris_source.py @@ -1,18 +1,21 @@ from sqlite3 import connect from time import time -from typing import Optional, Generator from src import shared 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.save_cover import resize_cover, save_cover class LutrisSourceIterator(SourceIterator): source: "LutrisSource" - def generator_builder(self) -> Generator[Optional[Game], None, None]: + def generator_builder(self) -> SourceIterationResult: """Generator method producing games""" # Query the database @@ -48,13 +51,12 @@ class LutrisSourceIterator(SourceIterator): } 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" - if image_path.exists(): - save_cover(values["game_id"], resize_cover(image_path)) + additional_data = {"local_image_path": image_path} # Produce game - yield game + yield (game, additional_data) class LutrisSource(Source): diff --git a/src/importer/sources/source.py b/src/importer/sources/source.py index 8fa4283..1df02b1 100644 --- a/src/importer/sources/source.py +++ b/src/importer/sources/source.py @@ -3,7 +3,7 @@ from abc import abstractmethod from collections.abc import Iterable, Iterator from functools import wraps from pathlib import Path -from typing import Generator, Any +from typing import Generator, Any, TypedDict from src import shared from src.game import Game diff --git a/src/importer/sources/steam_source.py b/src/importer/sources/steam_source.py index e93b9f3..9c6488c 100644 --- a/src/importer/sources/steam_source.py +++ b/src/importer/sources/steam_source.py @@ -1,18 +1,18 @@ import re from pathlib import Path from time import time -from typing import Iterable, Optional, Generator +from typing import Iterable from src import shared from src.game import Game from src.importer.sources.source import ( LinuxSource, Source, + SourceIterationResult, SourceIterator, WindowsSource, ) 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 @@ -44,7 +44,7 @@ class SteamSourceIterator(SourceIterator): ) return manifests - def generator_builder(self) -> Generator[Optional[Game], None, None]: + def generator_builder(self) -> SourceIterationResult: """Generator method producing games""" appid_cache = set() manifests = self.get_manifests() @@ -85,11 +85,10 @@ class SteamSourceIterator(SourceIterator): / "librarycache" / f"{appid}_library_600x900.jpg" ) - if image_path.is_file(): - save_cover(game.game_id, resize_cover(image_path)) + additional_data = {"local_image_path": image_path} # Produce game - yield game + yield (game, additional_data) class SteamSource(Source): diff --git a/src/main.py b/src/main.py index 1c05625..1ffa240 100644 --- a/src/main.py +++ b/src/main.py @@ -43,6 +43,8 @@ from src.store.managers.display_manager import DisplayManager from src.store.managers.file_manager import FileManager from src.store.managers.sgdb_manager import SGDBManager 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.window import CartridgesWindow @@ -85,7 +87,9 @@ class CartridgesApplication(Adw.Application): self.load_games_from_disk() # Add rest of the managers for game imports + shared.store.add_manager(LocalCoverManager()) shared.store.add_manager(SteamAPIManager()) + shared.store.add_manager(ItchCoverManager()) shared.store.add_manager(SGDBManager()) shared.store.add_manager(FileManager()) diff --git a/src/store/managers/async_manager.py b/src/store/managers/async_manager.py index a69f161..636b2bd 100644 --- a/src/store/managers/async_manager.py +++ b/src/store/managers/async_manager.py @@ -27,7 +27,7 @@ class AsyncManager(Manager): self.cancellable = Gio.Cancellable() def process_game( - self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any] + self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any] ) -> None: """Create a task to process the game in a separate thread""" task = Task.new(None, self.cancellable, self._task_callback, (callback,)) diff --git a/src/store/managers/display_manager.py b/src/store/managers/display_manager.py index cc0d82d..e79746e 100644 --- a/src/store/managers/display_manager.py +++ b/src/store/managers/display_manager.py @@ -10,7 +10,7 @@ class DisplayManager(Manager): 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 shared.win.games[game.game_id] = game game.update() diff --git a/src/store/managers/file_manager.py b/src/store/managers/file_manager.py index 69901ee..66cebc4 100644 --- a/src/store/managers/file_manager.py +++ b/src/store/managers/file_manager.py @@ -8,5 +8,5 @@ class FileManager(AsyncManager): 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() diff --git a/src/store/managers/itch_cover_manager.py b/src/store/managers/itch_cover_manager.py new file mode 100644 index 0000000..39b7928 --- /dev/null +++ b/src/store/managers/itch_cover_manager.py @@ -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 diff --git a/src/store/managers/local_cover_manager.py b/src/store/managers/local_cover_manager.py new file mode 100644 index 0000000..a191f36 --- /dev/null +++ b/src/store/managers/local_cover_manager.py @@ -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)) diff --git a/src/store/managers/manager.py b/src/store/managers/manager.py index 5b1b7d6..732b868 100644 --- a/src/store/managers/manager.py +++ b/src/store/managers/manager.py @@ -49,7 +49,7 @@ class Manager: return errors @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 * Implemented by final child classes @@ -59,7 +59,7 @@ class Manager: """ 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: """Execute the manager logic and handle its errors by reporting them or retrying""" try: @@ -93,7 +93,7 @@ class Manager: ) def process_game( - self, game: Game, additional_data: tuple, callback: Callable[["Manager"], Any] + self, game: Game, additional_data: dict, callback: Callable[["Manager"], Any] ) -> None: """Pass the game through the manager""" self.execute_resilient_manager_logic(game, additional_data) diff --git a/src/store/managers/sgdb_manager.py b/src/store/managers/sgdb_manager.py index 5b902df..08cfa88 100644 --- a/src/store/managers/sgdb_manager.py +++ b/src/store/managers/sgdb_manager.py @@ -2,6 +2,8 @@ from urllib3.exceptions import SSLError from src.game import Game 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.utils.steamgriddb import HTTPError, SGDBAuthError, SGDBHelper @@ -9,10 +11,10 @@ from src.utils.steamgriddb import HTTPError, SGDBAuthError, SGDBHelper class SGDBManager(AsyncManager): """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)) - def manager_logic(self, game: Game, _additional_data: tuple) -> None: + def manager_logic(self, game: Game, _additional_data: dict) -> None: try: sgdb = SGDBHelper() sgdb.conditionaly_update_cover(game) diff --git a/src/store/managers/steam_api_manager.py b/src/store/managers/steam_api_manager.py index 500a358..4f7d0c3 100644 --- a/src/store/managers/steam_api_manager.py +++ b/src/store/managers/steam_api_manager.py @@ -15,11 +15,10 @@ class SteamAPIManager(AsyncManager): 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 if not game.source.startswith("steam_"): return - # Get online metadata appid = str(game.game_id).split("_")[-1] steam = SteamHelper() diff --git a/src/store/pipeline.py b/src/store/pipeline.py index a13253c..af1b64b 100644 --- a/src/store/pipeline.py +++ b/src/store/pipeline.py @@ -11,14 +11,14 @@ class Pipeline(GObject.Object): """Class representing a set of managers for a game""" game: Game - additional_data: tuple + additional_data: dict waiting: set[Manager] running: set[Manager] done: set[Manager] def __init__( - self, game: Game, additional_data: tuple, managers: Iterable[Manager] + self, game: Game, additional_data: dict, managers: Iterable[Manager] ) -> None: super().__init__() self.game = game diff --git a/src/store/store.py b/src/store/store.py index 5ecd79a..14ad917 100644 --- a/src/store/store.py +++ b/src/store/store.py @@ -21,7 +21,7 @@ class Store: self.managers.add(manager) def add_game( - self, game: Game, additional_data: tuple, replace=False + self, game: Game, additional_data: dict, replace=False ) -> Pipeline | None: """Add a game to the app if not already there