Files
romm/backend/handler/filesystem/resources_handler.py
Michael Manganiello a9ac322618 fix: Correctly resize and save small artwork
The previous implementation was calling `resize_cover_to_small` within
the context manager that was writing the image to the filesystem. This
was causing `PIL` to raise an error because it could not identify the
open and temporarily created file as a valid image.

Instead of saving the original image to the filesystem and then resizing
it, we now open the image in memory, resize it, and then save it to the
filesystem. We also avoid reading the `BytesIO` object twice by saving
small and big images from the same initial `Image` object.

Fixes #1191.
2024-10-12 00:55:06 -03:00

206 lines
7.6 KiB
Python

import shutil
import httpx
from anyio import Path, open_file
from config import RESOURCES_BASE_PATH
from fastapi import HTTPException, status
from logger.logger import log
from models.collection import Collection
from models.rom import Rom
from PIL import Image, ImageFile
from utils.context import ctx_httpx_client
from .base_handler import CoverSize, FSHandler
class FSResourcesHandler(FSHandler):
@staticmethod
async def cover_exists(entity: Rom | Collection, size: CoverSize) -> bool:
"""Check if rom cover exists in filesystem
Args:
fs_slug: short name of the platform
rom_name: name of rom file
size: size of the cover
Returns
True if cover exists in filesystem else False
"""
async for _ in Path(
f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover"
).glob(f"{size.value}.*"):
# At least one file found.
return True
return False
@staticmethod
def resize_cover_to_small(cover: ImageFile.ImageFile, save_path: Path) -> None:
"""Resize cover to small size, and save it to filesystem."""
if cover.height >= 1000:
ratio = 0.2
else:
ratio = 0.4
small_width = int(cover.width * ratio)
small_height = int(cover.height * ratio)
small_size = (small_width, small_height)
small_img = cover.resize(small_size)
small_img.save(save_path)
async def _store_cover(
self, entity: Rom | Collection, url_cover: str, size: CoverSize
) -> None:
"""Store roms resources in filesystem
Args:
fs_slug: short name of the platform
rom_name: name of rom file
url_cover: url to get the cover
size: size of the cover
"""
cover_path = Path(f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover")
cover_file = cover_path / Path(f"{size.value}.png")
httpx_client = ctx_httpx_client.get()
try:
async with httpx_client.stream("GET", url_cover, timeout=120) as response:
if response.status_code == 200:
await cover_path.mkdir(parents=True, exist_ok=True)
async with await cover_file.open("wb") as f:
async for chunk in response.aiter_raw():
await f.write(chunk)
except httpx.NetworkError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Unable to fetch cover at {url_cover}: {str(exc)}",
) from exc
except httpx.ProtocolError:
log.warning(f"Failure writing cover {url_cover} to file (ProtocolError)")
if size == CoverSize.SMALL:
with Image.open(cover_file) as img:
self.resize_cover_to_small(img, save_path=cover_file)
@staticmethod
async def _get_cover_path(entity: Rom | Collection, size: CoverSize) -> str:
"""Returns rom cover filesystem path adapted to frontend folder structure
Args:
fs_slug: short name of the platform
file_name: name of rom file
size: size of the cover
"""
async for matched_file in Path(
f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover"
).glob(f"{size.value}.*"):
return str(matched_file.relative_to(RESOURCES_BASE_PATH))
return ""
async def get_cover(
self, entity: Rom | Collection | None, overwrite: bool, url_cover: str = ""
) -> tuple[str, str]:
if not entity:
return "", ""
small_cover_exists = await self.cover_exists(entity, CoverSize.SMALL)
if url_cover and (overwrite or not small_cover_exists):
await self._store_cover(entity, url_cover, CoverSize.SMALL)
small_cover_exists = await self.cover_exists(entity, CoverSize.SMALL)
path_cover_s = (
(await self._get_cover_path(entity, CoverSize.SMALL))
if small_cover_exists
else ""
)
big_cover_exists = await self.cover_exists(entity, CoverSize.BIG)
if url_cover and (overwrite or not big_cover_exists):
await self._store_cover(entity, url_cover, CoverSize.BIG)
big_cover_exists = await self.cover_exists(entity, CoverSize.BIG)
path_cover_l = (
(await self._get_cover_path(entity, CoverSize.BIG))
if big_cover_exists
else ""
)
return path_cover_s, path_cover_l
@staticmethod
def remove_cover(entity: Rom | Collection | None):
if not entity:
return {"path_cover_s": "", "path_cover_l": ""}
cover_path = f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover"
try:
shutil.rmtree(cover_path)
except FileNotFoundError:
log.warning(
f"Couldn't remove cover from '{entity.name or entity.id}' since '{cover_path}' doesn't exists."
)
return {"path_cover_s": "", "path_cover_l": ""}
@staticmethod
async def build_artwork_path(entity: Rom | Collection | None, file_ext: str):
if not entity:
return "", "", ""
path_cover = f"{entity.fs_resources_path}/cover"
path_cover_l = f"{path_cover}/{CoverSize.BIG.value}.{file_ext}"
path_cover_s = f"{path_cover}/{CoverSize.SMALL.value}.{file_ext}"
artwork_path = f"{RESOURCES_BASE_PATH}/{entity.fs_resources_path}/cover"
await Path(artwork_path).mkdir(parents=True, exist_ok=True)
return path_cover_l, path_cover_s, artwork_path
@staticmethod
async def _store_screenshot(rom: Rom, url: str, idx: int):
"""Store roms resources in filesystem
Args:
fs_slug: short name of the platform
file_name: name of rom
url: url to get the screenshot
"""
screenshot_file = f"{idx}.jpg"
screenshot_path = f"{RESOURCES_BASE_PATH}/{rom.fs_resources_path}/screenshots"
httpx_client = ctx_httpx_client.get()
try:
async with httpx_client.stream("GET", url, timeout=120) as response:
if response.status_code == 200:
await Path(screenshot_path).mkdir(parents=True, exist_ok=True)
async with await open_file(
f"{screenshot_path}/{screenshot_file}", "wb"
) as f:
async for chunk in response.aiter_raw():
await f.write(chunk)
except httpx.NetworkError as exc:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"Unable to fetch screenshot at {url}: {str(exc)}",
) from exc
except httpx.ProtocolError:
log.warning(f"Failure writing screenshot {url} to file (ProtocolError)")
@staticmethod
def _get_screenshot_path(rom: Rom, idx: str):
"""Returns rom cover filesystem path adapted to frontend folder structure
Args:
fs_slug: short name of the platform
file_name: name of rom
idx: index number of screenshot
"""
return f"{rom.fs_resources_path}/screenshots/{idx}.jpg"
async def get_rom_screenshots(
self, rom: Rom | None, url_screenshots: list
) -> list[str]:
if not rom:
return []
path_screenshots: list[str] = []
for idx, url in enumerate(url_screenshots):
await self._store_screenshot(rom, url, idx)
path_screenshots.append(self._get_screenshot_path(rom, str(idx)))
return path_screenshots