All checks were successful
Build plugin / build (push) Successful in 1m11s
When all available cover URLs are GIFs (coverUrl, stillCoverUrl, and screenshots), return empty instead of a GIF URL. HTTPImageFetcher cannot decode GIFs, so attempting to fetch one always returns null. Returning empty early avoids unnecessary network requests and confusing log noise.
327 lines
12 KiB
GDScript
327 lines
12 KiB
GDScript
extends BoxArtProvider
|
|
|
|
## itch.io box art provider for OpenGamepadUI
|
|
##
|
|
## Fetches cover art and screenshots from itch.io game pages. Works alongside
|
|
## the itch.io library plugin to provide per-layout artwork: the cover for
|
|
## portrait/logo, full-res screenshots for landscape/banner.
|
|
##
|
|
## Enriches game metadata by scraping the itch.io page HTML for screenshots
|
|
## (the itch.io API does not expose them), caches the result in
|
|
## itch_art_meta.json, and uses HTTPImageFetcher for async image downloads.
|
|
|
|
const BOXART_DIR := "user://boxart/itch"
|
|
const META_CACHE_FILE := "itch_art_meta.json"
|
|
const HTTP_TIMEOUT_MS := 15000
|
|
const SCREENSHOT_REGEX_PATTERN := "https://img\\.itch\\.zone/aW1hZ2Uv[\\w+/=]*/original/[\\w+/=]*\\.[a-z]+"
|
|
const IMAGE_EXTS := ["png", "jpg", "jpeg", "gif", "webp", "bmp"]
|
|
const CONNECT_POLL_DELAY_MS := 50
|
|
const READ_POLL_DELAY_MS := 10
|
|
const LOG_URL_MAX_LEN := 80
|
|
|
|
@export var use_caching: bool = true
|
|
var http_image := HTTPImageFetcher.new()
|
|
var _meta_cache_dir := "itch_art"
|
|
var _enriched_meta: Dictionary = {}
|
|
|
|
var layout_map: Dictionary = {
|
|
LAYOUT.GRID_PORTRAIT: "-portrait",
|
|
LAYOUT.GRID_LANDSCAPE: "-landscape",
|
|
LAYOUT.BANNER: "-banner",
|
|
LAYOUT.LOGO: "-logo",
|
|
}
|
|
|
|
|
|
func _init() -> void:
|
|
super()
|
|
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(BOXART_DIR))
|
|
provider_id = "itch"
|
|
logger_name = "BoxArtItch"
|
|
|
|
|
|
func _ready() -> void:
|
|
super()
|
|
_enriched_meta = _load_enriched_meta()
|
|
logger.info("itch.io Art Provider loaded")
|
|
add_child(http_image)
|
|
|
|
|
|
## Strips HTML srcset artifacts from a URL. Butlerd sometimes passes through
|
|
## itch.io cover URLs with trailing srcset descriptors like " 1x, h" or " 2x".
|
|
## These cause HTTPImageFetcher to fail because the URL is no longer valid.
|
|
func _sanitize_url(url: String) -> String:
|
|
if url.is_empty():
|
|
return url
|
|
# Srcset descriptors are separated by spaces — take only the URL portion.
|
|
var clean: String = url.split(" ")[0]
|
|
# Validate that the cleaned URL ends with a known image extension.
|
|
var ext: String = clean.get_extension().to_lower()
|
|
if ext not in IMAGE_EXTS:
|
|
logger.warn("Sanitized URL has unexpected extension '%s': %s" % [ext, clean.left(LOG_URL_MAX_LEN)])
|
|
return clean
|
|
|
|
|
|
## Returns whether a URL points to a GIF image. Handles both clean URLs and
|
|
## URLs contaminated with srcset artifacts (e.g. "image.gif 1x, h").
|
|
func _is_gif_url(url: String) -> bool:
|
|
return _sanitize_url(url).to_lower().ends_with(".gif")
|
|
|
|
|
|
func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
|
if not kind in layout_map:
|
|
logger.error("Unsupported boxart layout: {0}".format([kind]))
|
|
return null
|
|
|
|
var game := _find_game_dict(item)
|
|
if game.is_empty():
|
|
logger.warn("No itch.io game metadata found for: " + item.name)
|
|
return null
|
|
|
|
var game_id := int(game.get("id", 0))
|
|
var title: String = game.get("title", "")
|
|
logger.info("get_boxart: title=%s id=%d layout=%s" % [title, game_id, str(kind)])
|
|
if title.is_empty():
|
|
logger.warn("Game title is empty for id %d" % game_id)
|
|
return null
|
|
|
|
# Enrich on first access: scrape the itch.io page for screenshots.
|
|
# Cached failures (empty screenshots) are stored to avoid repeated
|
|
# requests against Cloudflare-protected pages.
|
|
var meta: Dictionary = _enriched_meta.get(game_id, {})
|
|
if meta.is_empty() and game.has("url"):
|
|
logger.info("Enriching metadata for: %s (url=%s)" % [title, game.get("url", "")])
|
|
meta = _fetch_game_page_metadata(game)
|
|
# Cache both successes and failures to avoid repeated requests.
|
|
_enriched_meta[game_id] = meta
|
|
_save_enriched_meta(game_id, meta)
|
|
if meta.is_empty():
|
|
logger.info("No screenshots available for: %s — will use cover art" % title)
|
|
else:
|
|
logger.info("Scraped %d screenshots for: %s" % [meta.get("screenshots", []).size(), title])
|
|
elif not meta.is_empty():
|
|
logger.info("Using cached metadata for: %s (%d screenshots)" % [title, meta.get("screenshots", []).size()])
|
|
else:
|
|
logger.warn("No URL in game dict for: %s" % title)
|
|
|
|
var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
|
|
# Filter out GIF screenshots — Godot can't decode them.
|
|
var non_gif_screenshots: Array = screenshots.filter(
|
|
func(url): return not _is_gif_url(url)
|
|
)
|
|
if screenshots.size() != non_gif_screenshots.size():
|
|
logger.info("Filtered %d GIF screenshots for: %s" % [screenshots.size() - non_gif_screenshots.size(), title])
|
|
|
|
var cover_url := _sanitize_url(_best_portrait_url(game, non_gif_screenshots))
|
|
if cover_url.is_empty():
|
|
logger.warn("No cover URL for: %s (id=%d)" % [title, game_id])
|
|
return null
|
|
logger.info("cover_url=%s" % cover_url.left(LOG_URL_MAX_LEN))
|
|
|
|
var cache_flags := Cache.FLAGS.NONE
|
|
if use_caching:
|
|
cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE
|
|
|
|
# Banner: combine two screenshots side-by-side when available.
|
|
if kind == LAYOUT.BANNER and non_gif_screenshots.size() >= 2:
|
|
logger.info("Fetching itch.io banner (2 screenshots) for: " + item.name)
|
|
var tex_a := await http_image.fetch(non_gif_screenshots[0], cache_flags)
|
|
var tex_b := await http_image.fetch(non_gif_screenshots[1], cache_flags)
|
|
if tex_a != null and tex_b != null:
|
|
return _combine_side_by_side(tex_a, tex_b)
|
|
# Banner combine failed — fall back to single-image path.
|
|
logger.warn("Banner screenshot fetch failed: tex_a=%s tex_b=%s" % [str(tex_a != null), str(tex_b != null)])
|
|
if tex_a != null:
|
|
return tex_a
|
|
if tex_b != null:
|
|
return tex_b
|
|
|
|
var url := _url_for_layout(kind, cover_url, non_gif_screenshots)
|
|
if url.is_empty():
|
|
logger.warn("URL for layout %s is empty (cover=%s)" % [str(kind), cover_url])
|
|
return null
|
|
|
|
logger.info("Fetching itch.io box art for: %s layout=%s url=%s" % [item.name, str(kind), url.left(LOG_URL_MAX_LEN)])
|
|
var texture: Texture2D = await http_image.fetch(url, cache_flags)
|
|
if texture == null:
|
|
logger.warn("Image download returned null for: %s url=%s" % [item.name, url.left(LOG_URL_MAX_LEN)])
|
|
return texture
|
|
|
|
|
|
## Finds the itch.io game dict from the library item's launch items.
|
|
func _find_game_dict(item: LibraryItem) -> Dictionary:
|
|
if item.launch_items.is_empty():
|
|
logger.warn("Item has no launch items: " + item.name)
|
|
return {}
|
|
for launch_item in item.launch_items:
|
|
if launch_item._provider_id != "itch":
|
|
continue
|
|
var game: Dictionary = launch_item.metadata.get("game", {})
|
|
if not game.is_empty():
|
|
return game
|
|
logger.warn("No itch.io launch item found for: %s (providers: %s)" % [
|
|
item.name,
|
|
PackedStringArray(item.launch_items.map(func(l): return l._provider_id))
|
|
])
|
|
return {}
|
|
|
|
|
|
func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> String:
|
|
match kind:
|
|
LAYOUT.GRID_PORTRAIT, LAYOUT.LOGO:
|
|
return cover_url
|
|
LAYOUT.GRID_LANDSCAPE:
|
|
# Prefer a non-GIF screenshot
|
|
for url in screenshots:
|
|
if not _is_gif_url(url):
|
|
return url
|
|
# cover_url is already sanitized and non-GIF by the time it reaches here
|
|
return cover_url
|
|
LAYOUT.BANNER:
|
|
# Single screenshot fallback (two-screenshot case handled in get_boxart).
|
|
# Prefer a non-GIF screenshot.
|
|
for url in screenshots:
|
|
if not _is_gif_url(url):
|
|
return url
|
|
return cover_url
|
|
return ""
|
|
|
|
|
|
## Returns a non-GIF URL suitable for portrait/logo from the game data.
|
|
## Falls back through: stillCoverUrl → coverUrl (non-GIF) → first non-GIF
|
|
## screenshot. Returns empty if only GIFs are available, since
|
|
## HTTPImageFetcher cannot decode GIF images.
|
|
func _best_portrait_url(game: Dictionary, screenshots: Array) -> String:
|
|
# Prefer stillCoverUrl if it's not a GIF
|
|
var still: String = game.get("stillCoverUrl", "")
|
|
if not still.is_empty() and not _is_gif_url(still):
|
|
return still
|
|
# Try coverUrl if it's not a GIF
|
|
var cover: String = game.get("coverUrl", "")
|
|
if not cover.is_empty() and not _is_gif_url(cover):
|
|
return cover
|
|
# Try the first non-GIF screenshot
|
|
for url in screenshots:
|
|
if not _is_gif_url(url):
|
|
return url
|
|
# All available URLs are GIFs — return empty so the caller gets null
|
|
# instead of attempting a doomed fetch.
|
|
return ""
|
|
|
|
|
|
## Combines two textures side-by-side into a single banner image.
|
|
## Both images are scaled to the same height, then placed left and right.
|
|
func _combine_side_by_side(left: Texture2D, right: Texture2D) -> Texture2D:
|
|
var img_a := left.get_image()
|
|
var img_b := right.get_image()
|
|
if img_a == null or img_b == null:
|
|
return left
|
|
|
|
# Scale both to the same height (use the taller one).
|
|
var target_h: int = max(img_a.get_height(), img_b.get_height())
|
|
if img_a.get_height() != target_h:
|
|
var scale: float = float(target_h) / float(img_a.get_height())
|
|
img_a.resize(int(img_a.get_width() * scale), target_h, Image.INTERPOLATE_BILINEAR)
|
|
if img_b.get_height() != target_h:
|
|
var scale: float = float(target_h) / float(img_b.get_height())
|
|
img_b.resize(int(img_b.get_width() * scale), target_h, Image.INTERPOLATE_BILINEAR)
|
|
|
|
var combined := Image.create(img_a.get_width() + img_b.get_width(), target_h, false, img_a.get_format())
|
|
combined.blit_rect(img_a, Rect2i(0, 0, img_a.get_width(), target_h), Vector2i.ZERO)
|
|
combined.blit_rect(img_b, Rect2i(0, 0, img_b.get_width(), target_h), Vector2i(img_a.get_width(), 0))
|
|
|
|
return ImageTexture.create_from_image(combined)
|
|
|
|
|
|
## Scrapes the itch.io game page for screenshots. The itch.io API does not
|
|
## expose screenshots, so the page HTML is the only source.
|
|
func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
|
|
var page_url: String = game.get("url", "")
|
|
if page_url.is_empty():
|
|
logger.warn("No page URL in game dict")
|
|
return {}
|
|
|
|
logger.info("Scraping page for screenshots: " + page_url)
|
|
var http := HTTPClient.new()
|
|
var parts := page_url.split("/")
|
|
var use_tls := parts[0] == "https:"
|
|
var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
|
|
if err != OK:
|
|
logger.warn("HTTP connect failed for %s: %d" % [page_url, err])
|
|
return {}
|
|
var deadline := Time.get_ticks_msec() + HTTP_TIMEOUT_MS
|
|
while http.get_status() == HTTPClient.STATUS_CONNECTING or http.get_status() == HTTPClient.STATUS_RESOLVING:
|
|
http.poll()
|
|
if Time.get_ticks_msec() > deadline:
|
|
http.close()
|
|
logger.warn("HTTP connect timed out for: " + page_url)
|
|
return {}
|
|
OS.delay_msec(CONNECT_POLL_DELAY_MS)
|
|
if http.get_status() != HTTPClient.STATUS_CONNECTED:
|
|
logger.warn("HTTP not connected for %s: status=%d" % [page_url, http.get_status()])
|
|
http.close()
|
|
return {}
|
|
|
|
http.request(HTTPClient.METHOD_GET, "/" + "/".join(parts.slice(3)), PackedStringArray())
|
|
deadline = Time.get_ticks_msec() + HTTP_TIMEOUT_MS
|
|
while http.get_status() == HTTPClient.STATUS_REQUESTING:
|
|
http.poll()
|
|
if Time.get_ticks_msec() > deadline:
|
|
http.close()
|
|
logger.warn("HTTP request timed out for: " + page_url)
|
|
return {}
|
|
OS.delay_msec(READ_POLL_DELAY_MS)
|
|
|
|
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()
|
|
logger.warn("HTTP body read timed out for: " + page_url)
|
|
return {}
|
|
OS.delay_msec(READ_POLL_DELAY_MS)
|
|
continue
|
|
body.append_array(chunk)
|
|
http.close()
|
|
|
|
var html := body.get_string_from_utf8()
|
|
if html.is_empty():
|
|
logger.warn("Empty HTML response for: " + page_url)
|
|
return {}
|
|
|
|
# Detect Cloudflare challenge pages — no point extracting screenshots.
|
|
if html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1:
|
|
logger.warn("Cloudflare challenge detected for: %s — falling back to cover art" % page_url)
|
|
return {}
|
|
|
|
var shot_re := RegEx.new()
|
|
shot_re.compile(SCREENSHOT_REGEX_PATTERN)
|
|
var screenshots: Array = []
|
|
for m in shot_re.search_all(html):
|
|
var url: String = _sanitize_url(m.get_string(0))
|
|
if _is_gif_url(url):
|
|
logger.debug("Skipping GIF screenshot: " + url)
|
|
continue
|
|
screenshots.append(url)
|
|
|
|
logger.info("Found %d screenshots for page %s" % [screenshots.size(), page_url])
|
|
return {"screenshots": screenshots}
|
|
|
|
|
|
func _load_enriched_meta() -> Dictionary:
|
|
var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
|
|
if typeof(raw) != TYPE_DICTIONARY:
|
|
return {}
|
|
var out := {}
|
|
for key in raw:
|
|
out[int(key)] = raw[key]
|
|
return out
|
|
|
|
|
|
func _save_enriched_meta(game_id: int, meta: Dictionary) -> void:
|
|
var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
|
|
if typeof(raw) != TYPE_DICTIONARY:
|
|
raw = {}
|
|
raw[str(game_id)] = meta
|
|
Cache.save_json(_meta_cache_dir, META_CACHE_FILE, raw)
|