Compare commits
7 Commits
v2.1.1
...
RilicTheFo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0440eee5d4 | ||
|
|
436a54ba5b | ||
|
|
1aff1347e3 | ||
|
|
e4dc1253ae | ||
|
|
b378110779 | ||
|
|
5708f48db8 | ||
|
|
9618fb7fff |
@@ -207,6 +207,20 @@ template $PreferencesWindow : Adw.PreferencesWindow {
|
||||
}
|
||||
}
|
||||
|
||||
Adw.ExpanderRow dolphin_expander_row {
|
||||
title: _("Dolphin");
|
||||
show-enable-switch: true;
|
||||
|
||||
Adw.ActionRow dolphin_cache_action_row {
|
||||
title: _("Cache Location");
|
||||
|
||||
Button dolphin_cache_file_chooser_button {
|
||||
icon-name: "folder-symbolic";
|
||||
valign: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Adw.ExpanderRow itch_expander_row {
|
||||
title: _("itch");
|
||||
show-enable-switch: true;
|
||||
|
||||
@@ -55,6 +55,12 @@
|
||||
<key name="bottles-location" type="s">
|
||||
<default>"~/.var/app/com.usebottles.bottles/data/bottles/"</default>
|
||||
</key>
|
||||
<key name="dolphin" type="b">
|
||||
<default>true</default>
|
||||
</key>
|
||||
<key name="dolphin-cache-location" type="s">
|
||||
<default>"~/.var/app/org.DolphinEmu.dolphin-emu/cache/dolphin-emu/"</default>
|
||||
</key>
|
||||
<key name="itch" type="b">
|
||||
<default>true</default>
|
||||
</key>
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"--socket=wayland",
|
||||
"--talk-name=org.freedesktop.Flatpak",
|
||||
"--filesystem=host:ro",
|
||||
"--filesystem=~/.var/app/org.DolphinEmu.dolphin-emu:ro",
|
||||
"--filesystem=~/.var/app/com.valvesoftware.Steam/data/Steam/:ro",
|
||||
"--filesystem=~/.var/app/net.lutris.Lutris/:ro",
|
||||
"--filesystem=~/.var/app/com.heroicgameslauncher.hgl/config/heroic/:ro",
|
||||
|
||||
98
src/importer/sources/dolphin_source.py
Normal file
98
src/importer/sources/dolphin_source.py
Normal file
@@ -0,0 +1,98 @@
|
||||
# dolphin_source.py
|
||||
#
|
||||
# Copyright 2023 Rilic
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
from typing import NamedTuple
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.importer.sources.location import Location, LocationSubPath
|
||||
from src.importer.sources.source import Source, SourceIterable
|
||||
from src.utils.dolphin_cache_reader import DolphinCacheReader
|
||||
|
||||
|
||||
class DolphinSourceIterable(SourceIterable):
|
||||
source: "DolphinSource"
|
||||
|
||||
def __iter__(self):
|
||||
added_time = int(time())
|
||||
|
||||
cache_reader = DolphinCacheReader(self.source.locations.cache["cache_file"])
|
||||
games_data = cache_reader.get_games()
|
||||
|
||||
for game_data in games_data:
|
||||
# Build game
|
||||
values = {
|
||||
"source": self.source.source_id,
|
||||
"added": added_time,
|
||||
"name": Path(game_data["file_name"]).stem,
|
||||
"game_id": self.source.game_id_format.format(
|
||||
game_id=game_data["game_id"]
|
||||
),
|
||||
"executable": self.source.executable_format.format(
|
||||
rom_path=game_data["file_path"],
|
||||
),
|
||||
}
|
||||
|
||||
game = Game(values)
|
||||
|
||||
image_path = Path(
|
||||
self.source.locations.cache["covers"] / (game_data["game_id"] + ".png")
|
||||
)
|
||||
additional_data = {"local_image_path": image_path}
|
||||
|
||||
yield (game, additional_data)
|
||||
|
||||
|
||||
class DolphinLocations(NamedTuple):
|
||||
cache: Location
|
||||
|
||||
|
||||
class DolphinSource(Source):
|
||||
name = _("Dolphin")
|
||||
source_id = "dolphin"
|
||||
available_on = {"linux"}
|
||||
iterable_class = DolphinSourceIterable
|
||||
|
||||
locations = DolphinLocations(
|
||||
Location(
|
||||
schema_key="dolphin-cache-location",
|
||||
candidates=[
|
||||
shared.flatpak_dir
|
||||
/ "org.DolphinEmu.dolphin-emu"
|
||||
/ "cache"
|
||||
/ "dolphin-emu",
|
||||
shared.home / ".cache" / "dolphin-emu",
|
||||
],
|
||||
paths={
|
||||
"cache_file": LocationSubPath("gamelist.cache"),
|
||||
"covers": LocationSubPath("GameCovers", True),
|
||||
},
|
||||
invalid_subtitle=Location.CACHE_INVALID_SUBTITLE,
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def executable_format(self):
|
||||
self.locations.cache.resolve()
|
||||
is_flatpak = self.locations.cache.root.is_relative_to(shared.flatpak_dir)
|
||||
base = "flatpak run org.DolphinEmu.dolphin-emu" if is_flatpak else "dolphin-emu"
|
||||
args = '-b -e "{rom_path}"'
|
||||
return f"{base} {args}"
|
||||
@@ -97,6 +97,7 @@ class SubSourceIterable(Iterable):
|
||||
"""Build a Game from a Heroic library entry"""
|
||||
|
||||
app_name = entry["app_name"]
|
||||
runner = entry["runner"]
|
||||
|
||||
# Build game
|
||||
values = {
|
||||
@@ -107,7 +108,7 @@ class SubSourceIterable(Iterable):
|
||||
"game_id": self.source.game_id_format.format(
|
||||
service=self.service, game_id=app_name
|
||||
),
|
||||
"executable": self.source.executable_format.format(app_name=app_name),
|
||||
"executable": self.source.executable_format.format(runner=runner, app_name=app_name),
|
||||
"hidden": self.source_iterable.is_hidden(app_name),
|
||||
}
|
||||
game = Game(values)
|
||||
@@ -359,7 +360,7 @@ class HeroicSource(URLExecutableSource):
|
||||
source_id = "heroic"
|
||||
name = _("Heroic")
|
||||
iterable_class = HeroicSourceIterable
|
||||
url_format = "heroic://launch/{app_name}"
|
||||
url_format = "heroic://launch/{runner}/{app_name}"
|
||||
available_on = {"linux", "win32"}
|
||||
|
||||
locations = HeroicLocations(
|
||||
|
||||
10
src/main.py
10
src/main.py
@@ -35,6 +35,7 @@ from src.details_window import DetailsWindow
|
||||
from src.game import Game
|
||||
from src.importer.importer import Importer
|
||||
from src.importer.sources.bottles_source import BottlesSource
|
||||
from src.importer.sources.dolphin_source import DolphinSource
|
||||
from src.importer.sources.flatpak_source import FlatpakSource
|
||||
from src.importer.sources.heroic_source import HeroicSource
|
||||
from src.importer.sources.itch_source import ItchSource
|
||||
@@ -45,8 +46,7 @@ from src.logging.setup import log_system_info, setup_logging
|
||||
from src.preferences import PreferencesWindow
|
||||
from src.store.managers.display_manager import DisplayManager
|
||||
from src.store.managers.file_manager import FileManager
|
||||
from src.store.managers.local_cover_manager import LocalCoverManager
|
||||
from src.store.managers.online_cover_manager import OnlineCoverManager
|
||||
from src.store.managers.cover_manager import CoverManager
|
||||
from src.store.managers.sgdb_manager import SGDBManager
|
||||
from src.store.managers.steam_api_manager import SteamAPIManager
|
||||
from src.store.store import Store
|
||||
@@ -97,9 +97,8 @@ 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(CoverManager())
|
||||
shared.store.add_manager(SteamAPIManager())
|
||||
shared.store.add_manager(OnlineCoverManager())
|
||||
shared.store.add_manager(SGDBManager())
|
||||
shared.store.toggle_manager_in_pipelines(FileManager, True)
|
||||
|
||||
@@ -230,6 +229,9 @@ class CartridgesApplication(Adw.Application):
|
||||
if shared.schema.get_boolean("bottles"):
|
||||
importer.add_source(BottlesSource())
|
||||
|
||||
if shared.schema.get_boolean("dolphin"):
|
||||
importer.add_source(DolphinSource())
|
||||
|
||||
if shared.schema.get_boolean("flatpak"):
|
||||
importer.add_source(FlatpakSource())
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ from gi.repository import Adw, Gio, GLib, Gtk
|
||||
|
||||
from src import shared
|
||||
from src.importer.sources.bottles_source import BottlesSource
|
||||
from src.importer.sources.dolphin_source import DolphinSource
|
||||
from src.importer.sources.flatpak_source import FlatpakSource
|
||||
from src.importer.sources.heroic_source import HeroicSource
|
||||
from src.importer.sources.itch_source import ItchSource
|
||||
@@ -75,6 +76,10 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
||||
bottles_data_action_row = Gtk.Template.Child()
|
||||
bottles_data_file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
dolphin_expander_row = Gtk.Template.Child()
|
||||
dolphin_cache_action_row = Gtk.Template.Child()
|
||||
dolphin_cache_file_chooser_button = Gtk.Template.Child()
|
||||
|
||||
itch_expander_row = Gtk.Template.Child()
|
||||
itch_config_action_row = Gtk.Template.Child()
|
||||
itch_config_file_chooser_button = Gtk.Template.Child()
|
||||
@@ -134,6 +139,7 @@ class PreferencesWindow(Adw.PreferencesWindow):
|
||||
# Sources settings
|
||||
for source_class in (
|
||||
BottlesSource,
|
||||
DolphinSource,
|
||||
FlatpakSource,
|
||||
HeroicSource,
|
||||
ItchSource,
|
||||
|
||||
@@ -56,7 +56,7 @@ class AsyncManager(Manager):
|
||||
def _task_thread_func(self, _task, _source_object, data, _cancellable):
|
||||
"""Task thread entry point"""
|
||||
game, additional_data, *_rest = data
|
||||
self.execute_resilient_manager_logic(game, additional_data)
|
||||
self.run(game, additional_data)
|
||||
|
||||
def _task_callback(self, _source_object, _result, data):
|
||||
"""Method run after the task is done"""
|
||||
|
||||
197
src/store/managers/cover_manager.py
Normal file
197
src/store/managers/cover_manager.py
Normal file
@@ -0,0 +1,197 @@
|
||||
# local_cover_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
# Copyright 2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import requests
|
||||
from gi.repository import Gio, GdkPixbuf
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
|
||||
from src import shared
|
||||
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 resize_cover, save_cover
|
||||
|
||||
|
||||
class ImageSize(NamedTuple):
|
||||
width: float = 0
|
||||
height: float = 0
|
||||
|
||||
@property
|
||||
def aspect_ratio(self) -> float:
|
||||
return self.width / self.height
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.width}x{self.height}"
|
||||
|
||||
def __mul__(self, scale: float | int) -> "ImageSize":
|
||||
return ImageSize(
|
||||
self.width * scale,
|
||||
self.height * scale,
|
||||
)
|
||||
|
||||
def __truediv__(self, divisor: float | int) -> "ImageSize":
|
||||
return self * (1 / divisor)
|
||||
|
||||
def __add__(self, other_size: "ImageSize") -> "ImageSize":
|
||||
return ImageSize(
|
||||
self.width + other_size.width,
|
||||
self.height + other_size.height,
|
||||
)
|
||||
|
||||
def __sub__(self, other_size: "ImageSize") -> "ImageSize":
|
||||
return self + (other_size * -1)
|
||||
|
||||
def element_wise_div(self, other_size: "ImageSize") -> "ImageSize":
|
||||
"""Divide every element of self by the equivalent in the other size"""
|
||||
return ImageSize(
|
||||
self.width / other_size.width,
|
||||
self.height / other_size.height,
|
||||
)
|
||||
|
||||
def element_wise_mul(self, other_size: "ImageSize") -> "ImageSize":
|
||||
"""Multiply every element of self by the equivalent in the other size"""
|
||||
return ImageSize(
|
||||
self.width * other_size.width,
|
||||
self.height * other_size.height,
|
||||
)
|
||||
|
||||
def invert(self) -> "ImageSize":
|
||||
"""Invert the element of self"""
|
||||
return ImageSize(1, 1).element_wise_div(self)
|
||||
|
||||
|
||||
class CoverManager(Manager):
|
||||
"""
|
||||
Manager in charge of adding the cover image of the game
|
||||
|
||||
Order of priority is:
|
||||
1. local cover
|
||||
2. icon cover
|
||||
3. online cover
|
||||
"""
|
||||
|
||||
run_after = (SteamAPIManager,)
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
||||
|
||||
def download_image(self, url: str) -> Path:
|
||||
image_file = Gio.File.new_tmp()[0]
|
||||
path = Path(image_file.get_path())
|
||||
with requests.get(url, timeout=5) as cover:
|
||||
cover.raise_for_status()
|
||||
path.write_bytes(cover.content)
|
||||
return path
|
||||
|
||||
def is_stretchable(self, source_size: ImageSize, cover_size: ImageSize) -> bool:
|
||||
is_taller = source_size.aspect_ratio < cover_size.aspect_ratio
|
||||
if is_taller:
|
||||
return True
|
||||
max_stretch = 0.12
|
||||
resized_height = (1 / source_size.aspect_ratio) * cover_size.width
|
||||
stretch = 1 - (resized_height / cover_size.height)
|
||||
return stretch <= max_stretch
|
||||
|
||||
def save_composited_cover(
|
||||
self,
|
||||
game: Game,
|
||||
image_path: Path,
|
||||
scale: float = 1,
|
||||
blur_size: ImageSize = ImageSize(2, 2),
|
||||
) -> None:
|
||||
"""
|
||||
Save the image composited with a background blur.
|
||||
If the image is stretchable, just stretch it.
|
||||
|
||||
:param game: The game to save the cover for
|
||||
:param path: Path where the source image is located
|
||||
:param scale:
|
||||
Scale of the smalled image side
|
||||
compared to the corresponding side in the cover
|
||||
:param blur_size: Size of the downscaled image used for the blur
|
||||
"""
|
||||
|
||||
# Load source image
|
||||
source = GdkPixbuf.Pixbuf.new_from_file(str(image_path))
|
||||
source_size = ImageSize(source.get_width(), source.get_height())
|
||||
cover_size = ImageSize._make(shared.image_size)
|
||||
|
||||
# Stretch if possible
|
||||
if scale == 1 and self.is_stretchable(source_size, cover_size):
|
||||
save_cover(game.game_id, resize_cover(pixbuf=source))
|
||||
return
|
||||
|
||||
# Create the blurred cover background
|
||||
# fmt: off
|
||||
cover = (
|
||||
source
|
||||
.scale_simple(*blur_size, GdkPixbuf.InterpType.BILINEAR)
|
||||
.scale_simple(*cover_size, GdkPixbuf.InterpType.BILINEAR)
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
# Scale to fit, apply scaling, then center
|
||||
uniform_scale = scale * min(cover_size.element_wise_div(source_size))
|
||||
source_in_cover_size = source_size * uniform_scale
|
||||
source_in_cover_position = (cover_size - source_in_cover_size) / 2
|
||||
|
||||
# Center the scaled source image in the cover
|
||||
source.composite(
|
||||
cover,
|
||||
*source_in_cover_position,
|
||||
*source_in_cover_size,
|
||||
*source_in_cover_position,
|
||||
uniform_scale,
|
||||
uniform_scale,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255,
|
||||
)
|
||||
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
||||
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
if game.blacklisted:
|
||||
return
|
||||
for key in (
|
||||
"local_image_path",
|
||||
"local_icon_path",
|
||||
"online_cover_url",
|
||||
):
|
||||
# Get an image path
|
||||
if not (value := additional_data.get(key)):
|
||||
continue
|
||||
if key == "online_cover_url":
|
||||
image_path = self.download_image(value)
|
||||
else:
|
||||
image_path = Path(value)
|
||||
if not image_path.is_file():
|
||||
continue
|
||||
|
||||
# Icon cover
|
||||
if key == "local_icon_path":
|
||||
self.save_composited_cover(
|
||||
game,
|
||||
image_path,
|
||||
scale=0.7,
|
||||
blur_size=ImageSize(1, 2),
|
||||
)
|
||||
return
|
||||
|
||||
self.save_composited_cover(game, image_path)
|
||||
@@ -30,7 +30,7 @@ class DisplayManager(Manager):
|
||||
run_after = (SteamAPIManager, SGDBManager)
|
||||
signals = {"update-ready"}
|
||||
|
||||
def manager_logic(self, game: Game, _additional_data: dict) -> None:
|
||||
def main(self, game: Game, _additional_data: dict) -> None:
|
||||
if game.get_parent():
|
||||
game.get_parent().get_parent().remove(game)
|
||||
if game.get_parent():
|
||||
|
||||
@@ -31,7 +31,7 @@ class FileManager(AsyncManager):
|
||||
run_after = (SteamAPIManager,)
|
||||
signals = {"save-ready"}
|
||||
|
||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
if additional_data.get("skip_save"): # Skip saving when loading games from disk
|
||||
return
|
||||
|
||||
|
||||
@@ -1,74 +0,0 @@
|
||||
# local_cover_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
# Copyright 2023 kramo
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
|
||||
from gi.repository import GdkPixbuf
|
||||
|
||||
from src import shared
|
||||
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 resize_cover, save_cover
|
||||
|
||||
|
||||
class LocalCoverManager(Manager):
|
||||
"""Manager in charge of adding the local cover image of the game"""
|
||||
|
||||
run_after = (SteamAPIManager,)
|
||||
|
||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
||||
if image_path := additional_data.get("local_image_path"):
|
||||
if not image_path.is_file():
|
||||
logging.error("Local image path is not a file: %s", image_path)
|
||||
return
|
||||
save_cover(game.game_id, resize_cover(image_path))
|
||||
elif icon_path := additional_data.get("local_icon_path"):
|
||||
cover_width, cover_height = shared.image_size
|
||||
|
||||
dest_width = cover_width * 0.7
|
||||
dest_height = cover_width * 0.7
|
||||
|
||||
dest_x = cover_width * 0.15
|
||||
dest_y = (cover_height - dest_height) / 2
|
||||
|
||||
image = GdkPixbuf.Pixbuf.new_from_file(str(icon_path)).scale_simple(
|
||||
dest_width, dest_height, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
|
||||
cover = image.scale_simple(
|
||||
1, 2, GdkPixbuf.InterpType.BILINEAR
|
||||
).scale_simple(cover_width, cover_height, GdkPixbuf.InterpType.BILINEAR)
|
||||
|
||||
image.composite(
|
||||
cover,
|
||||
dest_x,
|
||||
dest_y,
|
||||
dest_width,
|
||||
dest_height,
|
||||
dest_x,
|
||||
dest_y,
|
||||
1,
|
||||
1,
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255,
|
||||
)
|
||||
|
||||
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
||||
@@ -50,7 +50,7 @@ class Manager(ErrorProducer):
|
||||
return type(self).__name__
|
||||
|
||||
@abstractmethod
|
||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
||||
def main(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(ErrorProducer):
|
||||
* May raise other exceptions that will be reported
|
||||
"""
|
||||
|
||||
def execute_resilient_manager_logic(self, game: Game, additional_data: dict):
|
||||
def run(self, game: Game, additional_data: dict):
|
||||
"""Handle errors (retry, ignore or raise) that occur in the manager logic"""
|
||||
|
||||
# Keep track of the number of tries
|
||||
@@ -106,7 +106,7 @@ class Manager(ErrorProducer):
|
||||
|
||||
def try_manager_logic():
|
||||
try:
|
||||
self.manager_logic(game, additional_data)
|
||||
self.main(game, additional_data)
|
||||
except Exception as error: # pylint: disable=broad-exception-caught
|
||||
handle_error(error)
|
||||
|
||||
@@ -116,5 +116,5 @@ class Manager(ErrorProducer):
|
||||
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)
|
||||
self.run(game, additional_data)
|
||||
callback(self)
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
# online_cover_manager.py
|
||||
#
|
||||
# Copyright 2023 Geoffrey Coulaud
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify
|
||||
# it under the terms of the GNU General Public License as published by
|
||||
# the Free Software Foundation, either version 3 of the License, or
|
||||
# (at your option) any later version.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful,
|
||||
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
# GNU General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
#
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
from gi.repository import Gio, GdkPixbuf
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
from PIL import Image
|
||||
|
||||
from src import shared
|
||||
from src.game import Game
|
||||
from src.store.managers.local_cover_manager import LocalCoverManager
|
||||
from src.store.managers.manager import Manager
|
||||
from src.utils.save_cover import resize_cover, save_cover
|
||||
|
||||
|
||||
class OnlineCoverManager(Manager):
|
||||
"""Manager that downloads game covers from URLs"""
|
||||
|
||||
run_after = (LocalCoverManager,)
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
||||
|
||||
def save_composited_cover(
|
||||
self,
|
||||
game: Game,
|
||||
image_file: Gio.File,
|
||||
original_width: int,
|
||||
original_height: int,
|
||||
target_width: int,
|
||||
target_height: int,
|
||||
) -> None:
|
||||
"""Save the image composited with a background blur to fit the cover size"""
|
||||
|
||||
logging.debug(
|
||||
"Compositing image for %s (%s) %dx%d -> %dx%d",
|
||||
game.name,
|
||||
game.game_id,
|
||||
original_width,
|
||||
original_height,
|
||||
target_width,
|
||||
target_height,
|
||||
)
|
||||
|
||||
# Load game image
|
||||
image = GdkPixbuf.Pixbuf.new_from_stream(image_file.read())
|
||||
|
||||
# Create background blur of the size of the cover
|
||||
cover = image.scale_simple(2, 2, GdkPixbuf.InterpType.BILINEAR).scale_simple(
|
||||
target_width, target_height, GdkPixbuf.InterpType.BILINEAR
|
||||
)
|
||||
|
||||
# Center the image above the blurred background
|
||||
scale = min(target_width / original_width, target_height / original_height)
|
||||
left_padding = (target_width - original_width * scale) / 2
|
||||
top_padding = (target_height - original_height * scale) / 2
|
||||
image.composite(
|
||||
cover,
|
||||
# Top left of overwritten area on the destination
|
||||
left_padding,
|
||||
top_padding,
|
||||
# Size of the overwritten area on the destination
|
||||
original_width * scale,
|
||||
original_height * scale,
|
||||
# Offset
|
||||
left_padding,
|
||||
top_padding,
|
||||
# Scale to apply to the resized image
|
||||
scale,
|
||||
scale,
|
||||
# Compositing stuff
|
||||
GdkPixbuf.InterpType.BILINEAR,
|
||||
255,
|
||||
)
|
||||
|
||||
# Resize and save the cover
|
||||
save_cover(game.game_id, resize_cover(pixbuf=cover))
|
||||
|
||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
||||
# Ensure that we have a cover to download
|
||||
cover_url = additional_data.get("online_cover_url")
|
||||
if not cover_url:
|
||||
return
|
||||
|
||||
# Download cover
|
||||
image_file = Gio.File.new_tmp()[0]
|
||||
image_path = Path(image_file.get_path())
|
||||
with requests.get(cover_url, timeout=5) as cover:
|
||||
cover.raise_for_status()
|
||||
image_path.write_bytes(cover.content)
|
||||
|
||||
# Get image size
|
||||
cover_width, cover_height = shared.image_size
|
||||
with Image.open(image_path) as pil_image:
|
||||
width, height = pil_image.size
|
||||
|
||||
# Composite if the image is shorter and the stretch amount is too high
|
||||
aspect_ratio = width / height
|
||||
target_aspect_ratio = cover_width / cover_height
|
||||
is_taller = aspect_ratio < target_aspect_ratio
|
||||
resized_height = height / width * cover_width
|
||||
stretch = 1 - (resized_height / cover_height)
|
||||
max_stretch = 0.12
|
||||
if is_taller or stretch <= max_stretch:
|
||||
save_cover(game.game_id, resize_cover(image_path))
|
||||
else:
|
||||
self.save_composited_cover(
|
||||
game, image_file, width, height, cover_width, cover_height
|
||||
)
|
||||
@@ -24,19 +24,18 @@ from requests.exceptions import HTTPError, SSLError
|
||||
from src.errors.friendly_error import FriendlyError
|
||||
from src.game import Game
|
||||
from src.store.managers.async_manager import AsyncManager
|
||||
from src.store.managers.local_cover_manager import LocalCoverManager
|
||||
from src.store.managers.online_cover_manager import OnlineCoverManager
|
||||
from src.store.managers.steam_api_manager import SteamAPIManager
|
||||
from src.store.managers.cover_manager import CoverManager
|
||||
from src.utils.steamgriddb import SGDBAuthError, SGDBHelper
|
||||
|
||||
|
||||
class SGDBManager(AsyncManager):
|
||||
"""Manager in charge of downloading a game's cover from steamgriddb"""
|
||||
|
||||
run_after = (SteamAPIManager, LocalCoverManager, OnlineCoverManager)
|
||||
run_after = (SteamAPIManager, CoverManager)
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError, JSONDecodeError)
|
||||
|
||||
def manager_logic(self, game: Game, _additional_data: dict) -> None:
|
||||
def main(self, game: Game, _additional_data: dict) -> None:
|
||||
try:
|
||||
sgdb = SGDBHelper()
|
||||
sgdb.conditionaly_update_cover(game)
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
# SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
from requests.exceptions import HTTPError, SSLError
|
||||
from urllib3.exceptions import ConnectionError as Urllib3ConnectionError
|
||||
|
||||
from src.game import Game
|
||||
from src.store.managers.async_manager import AsyncManager
|
||||
@@ -32,7 +33,7 @@ from src.utils.steam import (
|
||||
class SteamAPIManager(AsyncManager):
|
||||
"""Manager in charge of completing a game's data from the Steam API"""
|
||||
|
||||
retryable_on = (HTTPError, SSLError, ConnectionError)
|
||||
retryable_on = (HTTPError, SSLError, Urllib3ConnectionError)
|
||||
|
||||
steam_api_helper: SteamAPIHelper = None
|
||||
steam_rate_limiter: SteamRateLimiter = None
|
||||
@@ -42,7 +43,7 @@ class SteamAPIManager(AsyncManager):
|
||||
self.steam_rate_limiter = SteamRateLimiter()
|
||||
self.steam_api_helper = SteamAPIHelper(self.steam_rate_limiter)
|
||||
|
||||
def manager_logic(self, game: Game, additional_data: dict) -> None:
|
||||
def main(self, game: Game, additional_data: dict) -> None:
|
||||
# Skip non-steam games
|
||||
appid = additional_data.get("steam_appid", None)
|
||||
if appid is None:
|
||||
|
||||
@@ -130,7 +130,7 @@ class Store:
|
||||
# Connect signals
|
||||
for manager in self.managers.values():
|
||||
for signal in manager.signals:
|
||||
game.connect(signal, manager.execute_resilient_manager_logic)
|
||||
game.connect(signal, manager.run)
|
||||
|
||||
# Add the game to the store
|
||||
if not game.source in self.source_games:
|
||||
|
||||
146
src/utils/dolphin_cache_reader.py
Normal file
146
src/utils/dolphin_cache_reader.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Reads the Dolphin game database, stored in a binary format"""
|
||||
# Copyright 2022-2023 strycore - Lutris
|
||||
# Copyright 2023 Rilic
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
SUPPORTED_CACHE_VERSION = 24
|
||||
|
||||
|
||||
def get_hex_string(string):
|
||||
"""Return the hexadecimal representation of a string"""
|
||||
return " ".join("{:02x}".format(c) for c in string)
|
||||
|
||||
|
||||
def get_word_len(string):
|
||||
"""Return the length of a string as specified in the Dolphin format"""
|
||||
return int("0x" + "".join("{:02x}".format(c) for c in string[::-1]), 0)
|
||||
|
||||
|
||||
# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.h#L140
|
||||
# https://github.com/dolphin-emu/dolphin/blob/90a994f93780ef8a7cccfc02e00576692e0f2839/Source/Core/UICommon/GameFile.cpp#L318
|
||||
|
||||
|
||||
class DolphinCacheReader:
|
||||
header_size = 20
|
||||
structure = {
|
||||
"valid": "b",
|
||||
"file_path": "s",
|
||||
"file_name": "s",
|
||||
"file_size": 8,
|
||||
"volume_size": 8,
|
||||
"volume_size_type": 4,
|
||||
"is_datel_disc": 1,
|
||||
"is_nkit": 1,
|
||||
"short_names": "a",
|
||||
"long_names": "a",
|
||||
"short_makers": "a",
|
||||
"long_makers": "a",
|
||||
"descriptions": "a",
|
||||
"internal_name": "s",
|
||||
"game_id": "s",
|
||||
"gametdb_id": "s",
|
||||
"title_id": 8,
|
||||
"maker_id": "s",
|
||||
"region": 4,
|
||||
"country": 4,
|
||||
"platform": 1,
|
||||
"platform_": 3,
|
||||
"blob_type": 4,
|
||||
"block_size": 8,
|
||||
"compression_method": "s",
|
||||
"revision": 2,
|
||||
"disc_number": 1,
|
||||
"apploader_date": "s",
|
||||
"custom_name": "s",
|
||||
"custom_description": "s",
|
||||
"custom_maker": "s",
|
||||
"volume_banner": "i",
|
||||
"custom_banner": "i",
|
||||
"default_cover": "c",
|
||||
"custom_cover": "c",
|
||||
}
|
||||
|
||||
def __init__(self, cache_file: Path):
|
||||
self.offset = 0
|
||||
with open(cache_file, "rb") as dolphin_cache_file:
|
||||
self.cache_content = dolphin_cache_file.read()
|
||||
cache_version = get_word_len(self.cache_content[:4])
|
||||
if cache_version != SUPPORTED_CACHE_VERSION:
|
||||
logging.warning(
|
||||
"Dolphin cache version expected %s but found %s",
|
||||
SUPPORTED_CACHE_VERSION,
|
||||
cache_version,
|
||||
)
|
||||
|
||||
def get_game(self):
|
||||
game = {}
|
||||
for key, i in self.structure.items():
|
||||
if i == "s":
|
||||
game[key] = self.get_string()
|
||||
elif i == "b":
|
||||
game[key] = self.get_boolean()
|
||||
elif i == "a":
|
||||
game[key] = self.get_array()
|
||||
elif i == "i":
|
||||
game[key] = self.get_image()
|
||||
elif i == "c":
|
||||
game[key] = self.get_cover()
|
||||
else:
|
||||
game[key] = self.get_raw(i)
|
||||
return game
|
||||
|
||||
def get_games(self):
|
||||
self.offset += self.header_size
|
||||
games = []
|
||||
while self.offset < len(self.cache_content):
|
||||
try:
|
||||
games.append(self.get_game())
|
||||
except Exception as ex:
|
||||
logging.error("Failed to read Dolphin database: %s", ex)
|
||||
return games
|
||||
|
||||
def get_boolean(self):
|
||||
res = bool(get_word_len(self.cache_content[self.offset : self.offset + 1]))
|
||||
self.offset += 1
|
||||
return res
|
||||
|
||||
def get_array(self):
|
||||
array_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
array = {}
|
||||
for _i in range(array_len):
|
||||
array_key = self.get_raw(4)
|
||||
array[array_key] = self.get_string()
|
||||
return array
|
||||
|
||||
def get_image(self):
|
||||
data_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
res = self.cache_content[
|
||||
self.offset : self.offset + data_len * 4
|
||||
] # vector<u32>
|
||||
self.offset += data_len * 4
|
||||
width = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
height = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
return (width, height), res
|
||||
|
||||
def get_cover(self):
|
||||
array_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
return self.get_raw(array_len)
|
||||
|
||||
def get_raw(self, word_len):
|
||||
res = get_hex_string(self.cache_content[self.offset : self.offset + word_len])
|
||||
self.offset += word_len
|
||||
return res
|
||||
|
||||
def get_string(self):
|
||||
word_len = get_word_len(self.cache_content[self.offset : self.offset + 4])
|
||||
self.offset += 4
|
||||
string = self.cache_content[self.offset : self.offset + word_len]
|
||||
self.offset += word_len
|
||||
return string.decode("utf8")
|
||||
@@ -103,11 +103,11 @@ class SGDBHelper:
|
||||
|
||||
image_trunk = shared.covers_dir / game.game_id
|
||||
still = image_trunk.with_suffix(".tiff")
|
||||
uri_kwargs = image_trunk.with_suffix(".gif")
|
||||
animated = image_trunk.with_suffix(".gif")
|
||||
prefer_sgdb = shared.schema.get_boolean("sgdb-prefer")
|
||||
|
||||
# Do nothing if file present and not prefer SGDB
|
||||
if not prefer_sgdb and (still.is_file() or uri_kwargs.is_file()):
|
||||
if not prefer_sgdb and (still.is_file() or animated.is_file()):
|
||||
return
|
||||
|
||||
# Get ID for the game
|
||||
|
||||
Reference in New Issue
Block a user