From a9371bdbc2c22627724345478cee90b0d3c05dad Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 6 Aug 2026 18:29:47 -0300 Subject: [PATCH] Download itch.io cover art into OGPU's local boxart dir so games show real covers instead of the placeholder --- core/library_itch.gd | 121 +++++++++++++++++++++++++++++++++++++++++++ plugin.json | 2 +- 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/core/library_itch.gd b/core/library_itch.gd index a6fa8b1..2876327 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -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 -.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. diff --git a/plugin.json b/plugin.json index 90c5bfe..b1be80d 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.3", + "plugin.version": "0.1.4", "plugin.min-api-version": "1.1.0", "plugin.link": "https://github.com/YOUR_USERNAME/OpenGamepadUI-itch", "plugin.source": "https://github.com/YOUR_USERNAME/OpenGamepadUI-itch",