Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 92 additions & 55 deletions backend/handler/filesystem/resources_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,18 +175,20 @@ async def _discard_partial_file(self, relative_path: str) -> None:
except OSError as exc:
log.error(f"Unable to remove partial file {relative_path}: {str(exc)}")

async def _store_cover(
self, entity: Rom | Collection, url_cover: str, size: CoverSize
) -> None:
"""Store roms resources in filesystem
async def _store_cover(self, entity: Rom | Collection, url_cover: str) -> None:
"""Fetch a cover once and write both sizes.

The small cover is a downscale of the large one, so fetching the same
URL a second time would only re-retrieve bytes we already hold, and on
ScreenScraper would spend a second request from the account's quota.

Args:
fs_slug: short name of the platform
rom_name: name of rom file
entity: Rom or Collection object
url_cover: url to get the cover
size: size of the cover
"""
cover_file = f"{entity.fs_resources_path}/cover"
big_path = f"{cover_file}/{CoverSize.BIG.value}.png"
small_path = f"{cover_file}/{CoverSize.SMALL.value}.png"
await self.make_directory(cover_file)

# Handle local-file URIs from metadata handlers (gamelist, LaunchBox)
Expand All @@ -196,22 +198,12 @@ async def _store_cover(
if resolved is None or not await AnyioPath(resolved).exists():
log.warning(f"Cover file not found: {url_cover}")
return None
dest_path = f"{cover_file}/{size.value}.png"
# Small-size covers get resized in place, which would mutate
# the user's source image if the destination were a hardlink.
await self.copy_file(resolved, dest_path, allow_link=False)

if await self._discard_if_chroma_key(dest_path):
return None

if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
self.validate_path(f"{cover_file}/{size.value}.png"),
force=True,
)
# Covers are rewritten in place by later scans, which would
# mutate the user's source image through a hardlink.
await self.copy_file(resolved, big_path, allow_link=False)
except Exception as exc:
log.error(f"Unable to copy cover file {url_cover}: {str(exc)}")
await self._discard_partial_file(f"{cover_file}/{size.value}.png")
await self._discard_partial_file(big_path)
return None
else:
# Handle HTTP URLs
Expand All @@ -233,7 +225,7 @@ async def _store_cover(
)

async with await self.write_file_streamed(
path=cover_file, filename=f"{size.value}.png"
path=cover_file, filename=f"{CoverSize.BIG.value}.png"
) as f:
if is_gzipped:
# Content is gzipped, decompress it
Expand All @@ -249,43 +241,84 @@ async def _store_cover(
await f.write(chunk)

downloaded = True

# Inspecting and re-encoding the file is local work, so it runs
# once the provider's request slot has been handed back.
if downloaded:
if await self._discard_if_chroma_key(
f"{cover_file}/{size.value}.png"
):
return None

if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
self.validate_path(f"{cover_file}/{size.value}.png"),
force=True,
)
except httpx.TransportError as exc:
log.error(f"Unable to fetch cover at {url_cover}: {str(exc)}")
await self._discard_partial_file(f"{cover_file}/{size.value}.png")
await self._discard_partial_file(big_path)
return None
except OSError as exc:
log.error(f"Unable to write cover for {url_cover}: {str(exc)}")
await self._discard_partial_file(f"{cover_file}/{size.value}.png")
await self._discard_partial_file(big_path)
return None

if size == CoverSize.SMALL:
try:
image_path = self.validate_path(f"{cover_file}/{size.value}.png")
with Image.open(image_path) as img:
self.resize_cover_to_small(img, save_path=str(image_path))
if not downloaded:
return None

if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
self.validate_path(f"{cover_file}/{size.value}.png"), force=True
)
except UnidentifiedImageError as exc:
log.error(f"Unable to identify image {cover_file}: {str(exc)}")
# Inspecting and re-encoding the file is local work, so it runs once the
# provider's request slot has been handed back.
try:
if await self._discard_if_chroma_key(big_path):
# A small cover left by an earlier scan would outlive the large
# one it was derived from.
await self._discard_partial_file(small_path)
return None

with Image.open(self.validate_path(big_path)) as img:
self.resize_cover_to_small(
img, save_path=str(self.validate_path(small_path))
)

if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
self.validate_path(big_path), force=True
)
self.image_converter.convert_to_webp(
self.validate_path(small_path), force=True
)
except UnidentifiedImageError as exc:
# Undecodable bytes still satisfy cover_exists(), so keeping them
# would stop every later scan from refetching a working cover.
log.error(f"Unable to identify image {big_path}: {str(exc)}")
for path in (big_path, small_path):
await self._discard_partial_file(path)
except OSError as exc:
log.error(f"Unable to write cover for {url_cover}: {str(exc)}")
for path in (big_path, small_path):
await self._discard_partial_file(path)

async def _derive_small_cover(self, entity: Rom | Collection) -> None:
"""Rebuild a missing small cover from the large one already on disk.

The small cover is a downscale of the large one, so a half-written pair
is repaired locally instead of spending a request on bytes we hold.

Args:
entity: Rom or Collection object
"""
path_cover_l = self._get_cover_path(entity, CoverSize.BIG)
if not path_cover_l:
return

path_cover_s = (
f"{entity.fs_resources_path}/cover/"
f"{CoverSize.SMALL.value}{Path(path_cover_l).suffix}"
)

try:
with Image.open(self.validate_path(path_cover_l)) as img:
self.resize_cover_to_small(
img, save_path=str(self.validate_path(path_cover_s))
)

if ENABLE_SCHEDULED_CONVERT_IMAGES_TO_WEBP:
self.image_converter.convert_to_webp(
self.validate_path(path_cover_s), force=True
)
except (UnidentifiedImageError, OSError) as exc:
# Unlike a fresh download, these bytes weren't written here, so the
# large cover stays put and only the partial small one is dropped.
log.error(f"Unable to resize cover {path_cover_l}: {str(exc)}")
await self._discard_partial_file(path_cover_s)

def _get_cover_path(self, entity: Rom | Collection, size: CoverSize) -> str | None:
"""Returns rom cover filesystem path adapted to frontend folder structure

Expand All @@ -305,12 +338,16 @@ async def get_cover(
if not entity:
return None, None

# Download covers if URL provided and (overwriting or covers don't exist)
if url_cover:
if overwrite or not self.cover_exists(entity, CoverSize.SMALL):
await self._store_cover(entity, url_cover, CoverSize.SMALL)
if overwrite or not self.cover_exists(entity, CoverSize.BIG):
await self._store_cover(entity, url_cover, CoverSize.BIG)
has_cover_l = self.cover_exists(entity, CoverSize.BIG)
has_cover_s = self.cover_exists(entity, CoverSize.SMALL)

# A single fetch writes both sizes
if url_cover and (overwrite or not has_cover_l):
await self._store_cover(entity, url_cover)
elif has_cover_l and not has_cover_s:
# Refetching to recover the small cover would overwrite a large one
# that is already good, and lose it if the fetch fails.
await self._derive_small_cover(entity)

# Return paths for existing covers
path_cover_s = (
Expand Down
Loading