Managers refactor (#164)
The main reason for this is the compositing of both local and online covers with the same logic. It was a problem raised in #146 with some covers getting stretched. Changes: - Renamed and simplified managers methods - Created a generic `cover manager` - Added more retryable errors to `steam api manager` - Removed `local cover manager` and `online cover manager` - Reduced dependency on `PIL`
This commit is contained in:
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)
|
||||
Reference in New Issue
Block a user