Download itch.io cover art into OGPU's local boxart dir so games show real covers instead of the placeholder

This commit is contained in:
Jose Falanga 2026-08-06 18:29:47 -03:00
parent c4fdd2f974
commit a9371bdbc2
2 changed files with 122 additions and 1 deletions

View file

@ -77,6 +77,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
var items := [] as Array[LibraryLaunchItem]
for i in json_items:
items.append(LibraryLaunchItem.from_dict(i))
_queue_boxart(items)
return items
if not itch.is_logged_in:
@ -133,9 +134,129 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) ->
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
logger.warn("Unable to save itch.io apps cache")
_queue_boxart(items)
return items
## Queues background downloads of each game's cover art into OGPU's local
## boxart directory (user://boxart/local/). The built-in "local" BoxArtProvider
## picks those files up by <game name>-<layout>.png, so games show their real
## cover instead of the placeholder. Covers are only downloaded once (the task
## bails out if a layout file already exists).
func _queue_boxart(items: Array) -> void:
for i in items:
var item: LibraryLaunchItem = i
var game: Dictionary = item.metadata.get("game", {})
var title: String = game.get("title", "")
var cover_url: String = game.get("coverUrl", "")
if title.is_empty() or cover_url.is_empty():
continue
if FileAccess.file_exists("/".join([_boxart_dir(), title + "-portrait.png"])):
continue
itch.thread_group.scheduled_exec(_ensure_boxart.bind(game), 0)
## Downloads the given game's cover (blocking, runs on the shared thread) and
## writes it to every boxart layout. itch.zone only serves one fixed landscape
## image per game, and OGPU's boxart slots all keep-aspect (scale + crop), so a
## single image is reused for portrait, landscape, banner and logo.
func _ensure_boxart(game: Dictionary) -> void:
var title: String = game.get("title", "")
var cover_url: String = game.get("coverUrl", "")
if title.is_empty() or cover_url.is_empty():
return
var dir := _boxart_dir()
if FileAccess.file_exists("/".join([dir, title + "-portrait.png"])):
return
var body := _download_cover(cover_url)
if body.is_empty():
logger.warn("Unable to download cover art for '" + title + "'")
return
if not _is_raster_image(body):
# OGPU's local provider only loads png/jpg; animated gif covers (rare)
# can't be rendered, so skip them rather than write an unloadable file.
logger.warn("Skipping unsupported cover format for '" + title + "'")
return
DirAccess.make_dir_recursive_absolute(dir)
for layout in ["portrait", "landscape", "banner", "logo"]:
var path := "/".join([dir, title + "-" + layout + ".png"])
var file := FileAccess.open(path, FileAccess.WRITE)
if file:
file.store_buffer(body)
file.close()
logger.info("Downloaded boxart for '" + title + "'")
func _boxart_dir() -> String:
return ProjectSettings.globalize_path("user://boxart/local")
## Blocking HTTPS GET of a single cover image, for use on a background thread.
func _download_cover(url: String) -> PackedByteArray:
var parts := url.split("/")
var use_tls := parts[0] == "https:"
var http := HTTPClient.new()
var err: int = http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
if err != OK:
return PackedByteArray()
var deadline := Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(50)
if http.get_status() != HTTPClient.STATUS_CONNECTED:
http.close()
return PackedByteArray()
http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray())
deadline = Time.get_ticks_msec() + 15000
while http.get_status() == HTTPClient.STATUS_REQUESTING:
http.poll()
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
var body := PackedByteArray()
while http.get_status() == HTTPClient.STATUS_BODY:
http.poll()
var chunk: PackedByteArray = http.read_response_body_chunk()
if chunk.is_empty():
if Time.get_ticks_msec() > deadline:
http.close()
return PackedByteArray()
OS.delay_msec(10)
continue
body.append_array(chunk)
var code: int = http.get_response_code()
http.close()
if code != 200:
return PackedByteArray()
return body
## Returns true if the bytes look like a PNG or JPEG (the only formats OGPU's
## local boxart provider can load).
func _is_raster_image(body: PackedByteArray) -> bool:
if body.size() < 12:
return false
var png_magic := [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
var is_png := true
for i in png_magic.size():
if body[i] != png_magic[i]:
is_png = false
break
if is_png:
return true
return body[0] == 0xff and body[1] == 0xd8 and body[2] == 0xff
## Best-effort discovery of the game's launch executable inside its install
## folder. butlerd doesn't hand back a ready-to-exec command the way a
## "gog://" or "steam://" URI does, so we look at what's actually on disk.