Compare commits

..

24 commits

Author SHA1 Message Date
436865f3cb bump version to 0.1.20
All checks were successful
Build plugin / build (push) Successful in 1m10s
2026-08-20 01:10:30 -03:00
dfe94b92be refactor: remove animated GIF feature, keep static first-frame extraction
Some checks failed
Build plugin / build (push) Has been cancelled
AnimatedTexture is deprecated and broken in Godot 4.4. Strip all animated
GIF infrastructure (frame cycling, focus handler, settings UI, frame delay
parsing). GIFs are now always converted to a static first-frame PNG cached
to disk. Banner stitching disk cache and format detection fallback retained.
2026-08-20 01:09:02 -03:00
494a00bb2a fix: promote focus handler logs from DEBUG to INFO
All checks were successful
Build plugin / build (push) Successful in 1m13s
Default logger level is INFO, so all debug logs in _on_focus_changed
were silently filtered. Promoted to INFO to diagnose why the handler
never fires. Also log viewport connection on startup.

Bump to v0.1.19
2026-08-20 00:45:21 -03:00
d9e59a411f feat: disk caching for GIF frames and banners, debug focus handler
All checks were successful
Build plugin / build (push) Successful in 1m10s
- Persist animated GIF frames to user://boxart/itch/anim_frames/<hash>/
  so subsequent loads skip ffmpeg extraction entirely.
- Persist static GIF first-frame PNGs alongside for the same reason.
- Cache stitched banner images via OGPU Cache with a synthetic key
  from both screenshot URLs, avoiding re-stitch on every load.
- Add comprehensive debug logging to _on_focus_changed to diagnose
  why AnimatedTextures aren't playing (logs control name, type,
  GameCard ancestor, texture type).
- Fix plugin.json version to match git tag (was stuck at 0.1.14).

Bump to v0.1.18
2026-08-20 00:36:41 -03:00
49fdbe8f0f fix: start AnimatedTextures unpaused to fix race condition
All checks were successful
Build plugin / build (push) Successful in 1m10s
AnimatedTextures were created paused, relying on the gui_focus_changed
handler to unpause them. But get_boxart is async — by the time the
AnimatedTexture is set on the TextureRect, focus may already be on the
card, so gui_focus_changed never fires for it. Animation stays paused.

Start unpaused instead. The focus handler still pauses unfocused cards
and unpauses focused ones, so only the active card animates.

Bump to v0.1.17
2026-08-20 00:21:35 -03:00
157593e853 fix: handle itch.zone serving PNG for .jpg URLs
All checks were successful
Build plugin / build (push) Successful in 1m10s
itch.zone returns content-type: image/png for .jpg URLs. HTTPImageFetcher
uses URL extension to pick the decoder, causing load_jpg_from_buffer to
fail on PNG data. Added format detection fallback that checks Content-Type
headers when the normal fetch fails for .jpg/.jpeg URLs.

Bump to v0.1.16
2026-08-20 00:11:46 -03:00
ce30f647c8 fix: remove broken coroutine pattern that caused parse error
All checks were successful
Build plugin / build (push) Successful in 1m9s
GDScript coroutines cannot be stored without await like JS Promises.
Removed _pending_gif_requests and _do_fetch_gif_as_texture — the race
condition is not a practical issue since Godot is single-threaded and
the cache check prevents duplicate work after the first request completes.

Bump to v0.1.15
2026-08-19 23:57:58 -03:00
75a6244fd4 bump to v0.1.14
All checks were successful
Build plugin / build (push) Successful in 1m8s
2026-08-19 22:13:24 -03:00
2ed80ec8a1 fix: critical bugs from code review
All checks were successful
Build plugin / build (push) Successful in 1m6s
- Split get_boxart() into _enrich_metadata + _fetch_banner_textures (under 50 lines)
- Fix race condition: add _pending_gif_requests to prevent concurrent downloads
- Fix race condition: add is_instance_valid checks in _on_focus_changed
- Fix stale reference: check is_instance_valid on _focused_animated_texture
- Fix temp file collisions: use URL hash for unique temp file names
- Add timeout to HTTPRequest in _download_url_bytes (HTTP_TIMEOUT_MS was unused)
- Add bounds checks in _parse_gif_extension and _skip_gif_header for truncated GIFs
2026-08-19 21:31:57 -03:00
a54c90f7c0 refactor: simplify _fetch_game_page_metadata using shared helpers
All checks were successful
Build plugin / build (push) Successful in 1m5s
- Replace manual HTTP polling with _download_url_bytes
- Extract _is_cloudflare_challenge and _extract_screenshots_from_html
- All functions now under 50 lines
2026-08-19 21:19:33 -03:00
3f5c45a99d refactor: split long functions and fix AGENTS.md violations
All checks were successful
Build plugin / build (push) Successful in 1m8s
- Rename goos/goarch to os_name/arch_name (no domain jargon)
- Split _install_ffmpeg into _detect_os, _detect_arch, _build_ffmpeg_url, _download_url_bytes, _extract_ffmpeg
- Split _build_animated_gif_texture into _extract_gif_frames and _assemble_animated_texture
- Split _parse_gif_frame_delays into _skip_gif_header, _parse_gif_extension, _skip_gif_image_descriptor
- Replace _download_gif_bytes with _download_url_bytes (shared helper)
- All functions now under 50 lines
2026-08-19 21:17:00 -03:00
208dc58f28 feat: animated GIF covers with config toggle and focus-based pausing
All checks were successful
Build plugin / build (push) Successful in 1m8s
- Add settings menu with 'Animate GIF covers' toggle (default: off)
- When enabled: extract all GIF frames via ffmpeg, build AnimatedTexture
- When disabled: extract first frame only (static ImageTexture)
- AnimatedTexture starts paused — unpauses only when card has focus
- Focus-based pausing via gui_focus_changed signal (walks up to GameCard)
- Parse GIF89a frame delays for accurate animation speed
- Cache invalidation when setting changes (no restart needed)
- Cache AnimatedTextures in memory to avoid re-extraction
- Bump to v0.1.13
2026-08-19 21:11:04 -03:00
9be6847f14 feat: add ffmpeg-based GIF first-frame extraction and remove GIF filtering
All checks were successful
Build plugin / build (push) Successful in 1m8s
- Add ffmpeg download and installation (mirrors butler pattern)
- Add _fetch_gif_first_frame() that downloads GIF and extracts first frame
- Add _ensure_ffmpeg() that checks system PATH, bundled location, or downloads
- Add _fetch_image() that routes GIF URLs through ffmpeg, non-GIF through HTTPImageFetcher
- Remove GIF filtering from screenshots and cover URLs
- Simplify _url_for_layout since _fetch_image handles GIFs transparently
- Bump to v0.1.12
2026-08-19 20:48:59 -03:00
b9646e6a8c fix: always prefer stillCoverUrl for capsule art
All checks were successful
Build plugin / build (push) Successful in 1m10s
_bset_portrait_url was filtering GIF URLs, but stillCoverUrl is the
still image version by definition and should always be used when
available. This restores the original behavior where games with GIF
capsules get their still cover image.
2026-08-19 18:58:10 -03:00
98053e6ea7 fix: don't fall back to screenshots as capsule art
All checks were successful
Build plugin / build (push) Successful in 1m10s
_best_portrait_url was falling back to non-GIF screenshots when both
coverUrl and stillCoverUrl were GIFs. Screenshots are not capsule art,
so this produced wrong imagery. Now the function only returns cover
URLs and returns empty when only GIFs are available.
2026-08-19 18:49:29 -03:00
658e8552cb Stop returning GIF URLs from _best_portrait_url
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.
2026-08-19 18:29:34 -03:00
696123b55a Bump to v0.1.8 for clean rebuild
All checks were successful
Build plugin / build (push) Successful in 1m8s
2026-08-19 18:22:08 -03:00
1bde072983 Fix srcset-polluted URLs from butlerd, sanitize cover and screenshot URLs
All checks were successful
Build plugin / build (push) Successful in 1m13s
butlerd sometimes passes itch.io coverUrl/stillCoverUrl with trailing
srcset descriptors (e.g. ' 1x, h' or ' 1'). These polluted URLs cause
HTTPImageFetcher to fail because the URL is invalid and the extension
extraction breaks.

Changes:
- Add _sanitize_url() to strip srcset artifacts from URLs
- Add _is_gif_url() helper that works with both clean and polluted URLs
- Fix SCREENSHOT_REGEX_PATTERN to use [\w+/=]* instead of [^"]* to
  avoid crossing srcset boundaries in HTML
- Replace all .ends_with('.gif') checks with _is_gif_url()
- Sanitize all URLs from game metadata and HTML scraping
- Add IMAGE_EXTS constant for extension validation

Also bump to v0.1.7.
2026-08-19 18:08:35 -03:00
730b600ec0 Resolve merge conflict: cache failures, add Cloudflare detection, bump to v0.1.6
All checks were successful
Build plugin / build (push) Successful in 1m6s
2026-08-19 17:32:25 -03:00
d842605098 Bump version to v0.1.5
All checks were successful
Build plugin / build (push) Successful in 1m7s
2026-08-19 16:42:25 -03:00
e3281f7587 readability: rename constants, extract poll intervals, remove dead code
- BOXART_DIR, META_CACHE_FILE, _meta_cache_dir, _enriched_meta
- _find_game_dict, _combine_side_by_side(left, right)
- CONNECT_POLL_DELAY_MS, READ_POLL_DELAY_MS, LOG_URL_MAX_LEN
- Remove dead _cover_url function
- Fix fall-through comment, boxart_provider variable name
2026-08-19 16:41:33 -03:00
75f0463bc6 readability: extract magic constants
- HTTP_TIMEOUT_MS for HTTP connection/request timeout
- SCREENSHOT_REGEX_PATTERN for itch.io screenshot URL regex
2026-08-19 16:20:21 -03:00
d06cba872c GIF handling: skip GIFs in HTTPImageFetcher, filter GIF screenshots, fallback portrait URLs
All checks were successful
Build plugin / build (push) Successful in 1m11s
- HTTPImageFetcher: skip GIF requests early (Godot can't decode), add webp support, try fallback formats
- Art provider: filter GIF screenshots from banners, prefer non-GIF cover URLs via _best_portrait_url
- Fix variable ordering bug (meta used before definition)
2026-08-17 11:07:54 -03:00
8a5dc41064 Add comprehensive logging to art provider for diagnosing missing art
All checks were successful
Build plugin / build (push) Successful in 1m11s
2026-08-17 10:55:56 -03:00
5 changed files with 430 additions and 94 deletions

View file

@ -0,0 +1,25 @@
extends VBoxContainer
## Settings screen for the itch.io art provider.
##
## Provides a toggle for animated GIF covers. When enabled, GIF cover art
## is extracted frame-by-frame and displayed as an AnimatedTexture that
## pauses when the card loses focus.
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
const SETTING_SECTION := "plugin.artprovider"
const SETTING_ANIMATED_GIFS := "animated_gifs"
@onready var animated_toggle: Toggle = $%AnimatedGifsToggle
func _ready() -> void:
animated_toggle.button_pressed = settings_manager.get_value(
SETTING_SECTION, SETTING_ANIMATED_GIFS, false
) as bool
animated_toggle.toggled.connect(_on_animated_gifs_toggled)
func _on_animated_gifs_toggled(pressed: bool) -> void:
settings_manager.set_value(SETTING_SECTION, SETTING_ANIMATED_GIFS, pressed)

View file

@ -0,0 +1,20 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://plugins/itch-artprovider/core/artprovider_settings.gd" id="1"]
[ext_resource type="PackedScene" uid="uid://d1qb7euwlu7bh" path="res://core/ui/components/toggle.tscn" id="2"]
[node name="ArtProviderSettings" type="VBoxContainer"]
anchors_preset = 15
anchor_right = 1.0
anchor_bottom = 1.0
grow_horizontal = 2
grow_vertical = 2
theme_override_constants/separation = 10
script = ExtResource("1")
[node name="AnimatedGifsToggle" parent="." instance=ExtResource("2")]
unique_name_in_owner = true
layout_mode = 2
text = "Animate GIF covers"
description = "Extract all frames from animated GIF covers. Uses more memory but shows the full animation. Pauses when the card is not focused."
button_pressed = false

View file

@ -10,13 +10,23 @@ extends BoxArtProvider
## (the itch.io API does not expose them), caches the result in ## (the itch.io API does not expose them), caches the result in
## itch_art_meta.json, and uses HTTPImageFetcher for async image downloads. ## itch_art_meta.json, and uses HTTPImageFetcher for async image downloads.
const _boxart_dir := "user://boxart/itch" const BOXART_DIR := "user://boxart/itch"
const _meta_cache_file := "itch_art_meta.json" 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 FFMPEG_BUILDS_URL := "https://github.com/BtbN/FFmpeg-Builds/releases/download/latest"
const CONNECT_POLL_DELAY_MS := 50
const READ_POLL_DELAY_MS := 10
const LOG_URL_MAX_LEN := 80
const CACHE_DIR := "images"
const BANNER_CACHE_DIR := "banners"
const GIF_STATIC_DIR := "gif_static"
@export var use_caching: bool = true @export var use_caching: bool = true
var http_image := HTTPImageFetcher.new() var http_image := HTTPImageFetcher.new()
var _cache_dir := "itch_art" var _meta_cache_dir := "itch_art"
var _enriched: Dictionary = {} var _enriched_meta: Dictionary = {}
var layout_map: Dictionary = { var layout_map: Dictionary = {
LAYOUT.GRID_PORTRAIT: "-portrait", LAYOUT.GRID_PORTRAIT: "-portrait",
@ -25,81 +35,209 @@ var layout_map: Dictionary = {
LAYOUT.LOGO: "-logo", LAYOUT.LOGO: "-logo",
} }
## Resolved path to the ffmpeg binary (system or bundled). Populated lazily.
var _ffmpeg_bin: String = ""
func _init() -> void: func _init() -> void:
super() super()
DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(_boxart_dir)) var globalized := ProjectSettings.globalize_path(BOXART_DIR)
DirAccess.make_dir_recursive_absolute(globalized)
provider_id = "itch" provider_id = "itch"
logger_name = "BoxArtItch" logger_name = "BoxArtItch"
func _ready() -> void: func _ready() -> void:
super() super()
_enriched = _load_enriched_meta() _enriched_meta = _load_enriched_meta()
logger.info("itch.io Art Provider loaded") logger.info("itch.io Art Provider loaded")
add_child(http_image) 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".
func _sanitize_url(url: String) -> String:
if url.is_empty():
return url
var clean: String = url.split(" ")[0]
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.
func _is_gif_url(url: String) -> bool:
return _sanitize_url(url).to_lower().ends_with(".gif")
## Returns whether a URL points to a JPEG image.
func _is_jpg_url(url: String) -> bool:
var clean := _sanitize_url(url).to_lower()
return clean.ends_with(".jpg") or clean.ends_with(".jpeg")
## Removes and frees an HTTPRequest node.
func _remove_http(http: HTTPRequest) -> void:
remove_child(http)
http.queue_free()
## Downloads an image and detects the actual format from content-type headers.
## Used as a fallback when HTTPImageFetcher fails due to extension mismatch.
func _fetch_image_with_format_detection(url: String, cache_flags: int) -> Texture2D:
var http := HTTPRequest.new()
http.timeout = HTTP_TIMEOUT_MS / 1000.0
add_child.call_deferred(http)
await http.ready
if http.request(url) != OK:
_remove_http(http)
return null
var args: Array = await http.request_completed
var result: int = args[0]
var response_code: int = args[1]
var headers: PackedStringArray = args[2]
var body: PackedByteArray = args[3]
_remove_http(http)
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
return null
var content_type := ""
for h in headers:
if h.to_lower().begins_with("content-type:"):
content_type = h.split(":", true, 1)[1].strip_edges().to_lower()
break
var image := Image.new()
var err: int = ERR_INVALID_DATA
if content_type.find("png") != -1:
err = image.load_png_from_buffer(body)
elif content_type.find("jpeg") != -1 or content_type.find("jpg") != -1:
err = image.load_jpg_from_buffer(body)
elif content_type.find("webp") != -1:
err = image.load_webp_from_buffer(body)
else:
err = image.load_png_from_buffer(body)
if err != OK:
err = image.load_jpg_from_buffer(body)
if err != OK:
logger.warn("Format detection failed for %s (content-type: %s)" % [url.left(LOG_URL_MAX_LEN), content_type])
return null
var texture := ImageTexture.create_from_image(image)
if cache_flags & Cache.FLAGS.SAVE:
Cache.save_image(CACHE_DIR, url, texture)
return texture
func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D: func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
if not kind in layout_map: if not kind in layout_map:
logger.error("Unsupported boxart layout: {0}".format([kind])) logger.error("Unsupported boxart layout: {0}".format([kind]))
return null return null
var game := _itch_game(item) var game := _find_game_dict(item)
if game.is_empty(): if game.is_empty():
logger.warn("No itch.io game metadata found for: " + item.name)
return null return null
var game_id := int(game.get("id", 0)) var game_id := int(game.get("id", 0))
var title: String = game.get("title", "") var title: String = game.get("title", "")
var cover_url := _cover_url(game) logger.info("get_boxart: title=%s id=%d layout=%s" % [title, game_id, str(kind)])
if title.is_empty() or cover_url.is_empty(): if title.is_empty():
logger.warn("Game title is empty for id %d" % game_id)
return null return null
# Enrich on first access: scrape the itch.io page for screenshots. var meta := await _enrich_metadata(game, game_id, title)
var meta: Dictionary = _enriched.get(game_id, {}) var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
if meta.is_empty() and game.has("url"): var cover_url := _sanitize_url(_best_portrait_url(game, screenshots))
meta = _fetch_game_page_metadata(game) if cover_url.is_empty():
if not meta.is_empty(): logger.warn("No cover URL for: %s (id=%d)" % [title, game_id])
_enriched[game_id] = meta return null
_save_enriched_meta(game_id, meta) logger.info("cover_url=%s" % cover_url.left(LOG_URL_MAX_LEN))
var screenshots: Array = meta.get("screenshots", [])
var cache_flags := Cache.FLAGS.NONE var cache_flags := Cache.FLAGS.NONE
if use_caching: if use_caching:
cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE cache_flags = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE
# Banner: combine two screenshots side-by-side when available.
if kind == LAYOUT.BANNER and screenshots.size() >= 2: if kind == LAYOUT.BANNER and screenshots.size() >= 2:
logger.debug("Fetching itch.io banner (2 screenshots) for: " + item.name) return await _fetch_banner_textures(item, screenshots, cache_flags)
var tex_a := await http_image.fetch(screenshots[0], cache_flags)
var tex_b := await http_image.fetch(screenshots[1], cache_flags) var url := _url_for_layout(kind, cover_url, 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 _fetch_image(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
## Enriches game metadata by scraping the itch.io page for screenshots.
func _enrich_metadata(game: Dictionary, game_id: int, title: String) -> Dictionary:
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 = await _fetch_game_page_metadata(game)
_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)
return meta
## Fetches two screenshots and combines them side-by-side for banner layout.
func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D:
var banner_key: String = screenshots[0] + "||" + screenshots[1]
if cache_flags & Cache.FLAGS.LOAD and Cache.is_cached(BANNER_CACHE_DIR, banner_key):
var cached := Cache.get_image(BANNER_CACHE_DIR, banner_key)
if cached != null:
logger.info("Banner: loaded cached stitched banner for %s" % item.name)
return cached
logger.info("Fetching itch.io banner (2 screenshots) for: " + item.name)
var tex_a := await _fetch_image(screenshots[0], cache_flags)
var tex_b := await _fetch_image(screenshots[1], cache_flags)
if tex_a != null and tex_b != null: if tex_a != null and tex_b != null:
return _combine_side_by_side(tex_a, tex_b) var stitched := _combine_side_by_side(tex_a, tex_b)
# Fall through to single-image path if either failed. if cache_flags & Cache.FLAGS.SAVE and stitched != null:
Cache.save_image(BANNER_CACHE_DIR, banner_key, stitched)
return stitched
logger.warn("Banner screenshot fetch failed: tex_a=%s tex_b=%s" % [str(tex_a != null), str(tex_b != null)])
if tex_a != null: if tex_a != null:
return tex_a return tex_a
if tex_b != null: if tex_b != null:
return tex_b return tex_b
var url := _url_for_layout(kind, cover_url, screenshots)
if url.is_empty():
return null return null
logger.debug("Fetching itch.io box art for: " + item.name)
var texture: Texture2D = await http_image.fetch(url, cache_flags)
if texture == null:
logger.debug("Image couldn't be downloaded for: " + item.name)
return texture
## Finds the itch.io game dict from the library item's launch items. ## Finds the itch.io game dict from the library item's launch items.
func _itch_game(item: LibraryItem) -> Dictionary: 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: for launch_item in item.launch_items:
if launch_item._provider_id != "itch": if launch_item._provider_id != "itch":
continue continue
var game: Dictionary = launch_item.metadata.get("game", {}) var game: Dictionary = launch_item.metadata.get("game", {})
if not game.is_empty(): if not game.is_empty():
return game 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 {} return {}
@ -110,27 +248,207 @@ func _url_for_layout(kind: LAYOUT, cover_url: String, screenshots: Array) -> Str
LAYOUT.GRID_LANDSCAPE: LAYOUT.GRID_LANDSCAPE:
return screenshots[0] if not screenshots.is_empty() else cover_url return screenshots[0] if not screenshots.is_empty() else cover_url
LAYOUT.BANNER: LAYOUT.BANNER:
# Single screenshot fallback (two-screenshot case handled in get_boxart).
return screenshots[0] if not screenshots.is_empty() else cover_url return screenshots[0] if not screenshots.is_empty() else cover_url
return "" return ""
func _cover_url(game: Dictionary) -> String: ## Returns the best portrait/logo URL from the game data.
## Prefers stillCoverUrl (the still/non-animated version of the cover),
## then coverUrl. Screenshots are never used as capsule art.
func _best_portrait_url(game: Dictionary, _screenshots: Array) -> String:
var still: String = game.get("stillCoverUrl", "") var still: String = game.get("stillCoverUrl", "")
if not still.is_empty(): if not still.is_empty():
return still return still
return game.get("coverUrl", "") return game.get("coverUrl", "")
## Combines two textures side-by-side into a single banner image. ## Ensures an ffmpeg binary is available. Checks the system PATH first,
## Both images are scaled to the same height, then placed left and right. ## then falls back to a bundled copy under user://boxart/itch/.
func _combine_side_by_side(a: Texture2D, b: Texture2D) -> Texture2D: func _ensure_ffmpeg() -> String:
var img_a := a.get_image() if not _ffmpeg_bin.is_empty():
var img_b := b.get_image() return _ffmpeg_bin
if img_a == null or img_b == null: var probe: Array = []
return a OS.execute("ffmpeg", ["-version"], probe, true)
if probe.size() > 0 and probe[0].find("ffmpeg version") != -1:
_ffmpeg_bin = "ffmpeg"
logger.info("Using system ffmpeg")
return _ffmpeg_bin
var bundled: String = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg"
if FileAccess.file_exists(bundled):
_ffmpeg_bin = bundled
logger.info("Using bundled ffmpeg: " + _ffmpeg_bin)
return _ffmpeg_bin
logger.info("ffmpeg not found — downloading static build")
if await _install_ffmpeg():
_ffmpeg_bin = ProjectSettings.globalize_path(BOXART_DIR) + "/ffmpeg"
return _ffmpeg_bin
logger.warn("ffmpeg unavailable — GIF covers will not load")
return ""
## Downloads a static ffmpeg build.
func _install_ffmpeg() -> bool:
var os_name := _detect_os()
var arch_name := _detect_arch()
var archive_url := _build_ffmpeg_url(os_name, arch_name)
var body := await _download_url_bytes(archive_url)
if body.is_empty():
return false
return _extract_ffmpeg(body, os_name)
func _detect_os() -> String:
if OS.get_name() == "Windows":
return "win"
if OS.get_name() == "macOS":
return "macos"
return "linux"
func _detect_arch() -> String:
if Engine.has_method("get_architecture_name"):
var arch: String = Engine.get_architecture_name()
if "arm64" in arch or "aarch64" in arch:
return "arm64"
return "64"
func _build_ffmpeg_url(os_name: String, arch_name: String) -> String:
var platform_slug := os_name + arch_name
var archive_name := "ffmpeg-master-latest-%s-gpl" % platform_slug
var ext := "zip" if os_name == "win" else "tar.xz"
return "%s/%s.%s" % [FFMPEG_BUILDS_URL, archive_name, ext]
## Downloads bytes from a URL. Returns empty PackedByteArray on failure.
func _download_url_bytes(url: String) -> PackedByteArray:
var http := HTTPRequest.new()
http.timeout = HTTP_TIMEOUT_MS / 1000.0
add_child.call_deferred(http)
await http.ready
if http.request(url) != OK:
logger.error("Error requesting: " + url)
remove_child(http)
http.queue_free()
return PackedByteArray()
var args: Array = await http.request_completed
var result: int = args[0]
var response_code: int = args[1]
var body: PackedByteArray = args[3]
remove_child(http)
http.queue_free()
if result != HTTPRequest.RESULT_SUCCESS or response_code != 200:
logger.error("Download failed: HTTP %d for %s" % [response_code, url])
return PackedByteArray()
return body
## Extracts the ffmpeg binary from the downloaded archive.
func _extract_ffmpeg(body: PackedByteArray, os_name: String) -> bool:
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
DirAccess.make_dir_recursive_absolute(globalized_dir)
var is_win: bool = os_name == "win"
var ext := "zip" if is_win else "tar.xz"
var archive_path := globalized_dir + "/ffmpeg." + ext
var file := FileAccess.open(archive_path, FileAccess.WRITE)
if file == null:
logger.error("Cannot write ffmpeg archive to " + archive_path)
return false
file.store_buffer(body)
file.close()
var out := []
if is_win:
OS.execute("unzip", ["-o", archive_path, "-d", globalized_dir], out)
OS.execute("chmod", ["+x", globalized_dir + "/ffmpeg.exe"], out)
else:
OS.execute("tar", ["xf", archive_path, "-C", globalized_dir,
"--strip-components=1", "--wildcards", "*/ffmpeg"], out)
OS.execute("chmod", ["+x", globalized_dir + "/ffmpeg"], out)
DirAccess.remove_absolute(archive_path)
logger.info("ffmpeg installed to " + globalized_dir)
return true
## Unified image fetch. GIFs go through ffmpeg for first-frame extraction.
## Non-GIF URLs go through HTTPImageFetcher, with a format-detection
## fallback for .jpg URLs that are actually served as PNG by itch.zone.
func _fetch_image(url: String, cache_flags: int) -> Texture2D:
if _is_gif_url(url):
return await _fetch_gif_as_texture(url, cache_flags)
var texture := await http_image.fetch(url, cache_flags)
if texture == null and _is_jpg_url(url):
texture = await _fetch_image_with_format_detection(url, cache_flags)
return texture
## Downloads a GIF and extracts the first frame as a static ImageTexture.
## Results are cached on disk under user://boxart/itch/gif_static/<hash>.png
## and in memory for the session.
func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
DirAccess.make_dir_recursive_absolute(globalized_dir + "/" + GIF_STATIC_DIR)
var url_hash := url.sha256_text().left(16)
var cached_png := globalized_dir + "/" + GIF_STATIC_DIR + "/" + url_hash + ".png"
# Check disk cache.
if cache_flags & Cache.FLAGS.LOAD and FileAccess.file_exists(cached_png):
var image := Image.load_from_file(cached_png)
if image != null:
logger.info("GIF fetch: loaded cached static frame for %s" % url.left(LOG_URL_MAX_LEN))
return ImageTexture.create_from_image(image)
var ffmpeg_bin := await _ensure_ffmpeg()
if ffmpeg_bin.is_empty():
return null
var gif_path := globalized_dir + "/_tmp_%s.gif" % url_hash
var body := await _download_url_bytes(url)
if body.is_empty():
return null
var gif_file := FileAccess.open(gif_path, FileAccess.WRITE)
if gif_file == null:
logger.warn("GIF fetch: cannot write temp gif")
return null
gif_file.store_buffer(body)
gif_file.close()
# Extract first frame via ffmpeg.
var ffmpeg_args: PackedStringArray = [
"-y", "-i", gif_path,
"-frames:v", "1",
"-f", "image2", cached_png,
]
var ret: Array = []
OS.execute(ffmpeg_bin, ffmpeg_args, ret)
DirAccess.remove_absolute(gif_path)
if not FileAccess.file_exists(cached_png):
logger.warn("GIF fetch: ffmpeg did not produce output for %s" % url.left(LOG_URL_MAX_LEN))
return null
var image := Image.load_from_file(cached_png)
if image == null:
logger.warn("GIF fetch: failed to load extracted PNG")
return null
logger.info("GIF fetch: extracted first frame from %s" % url.left(LOG_URL_MAX_LEN))
return ImageTexture.create_from_image(image)
## Combines two textures side-by-side into a single banner image.
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()) var target_h: int = max(img_a.get_height(), img_b.get_height())
if img_a.get_height() != target_h: if img_a.get_height() != target_h:
var scale: float = float(target_h) / float(img_a.get_height()) var scale: float = float(target_h) / float(img_a.get_height())
@ -146,71 +464,44 @@ func _combine_side_by_side(a: Texture2D, b: Texture2D) -> Texture2D:
return ImageTexture.create_from_image(combined) return ImageTexture.create_from_image(combined)
## Scrapes the itch.io game page for screenshots. The itch.io API does not ## Scrapes the itch.io game page for screenshots.
## expose screenshots, so the page HTML is the only source.
func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: func _fetch_game_page_metadata(game: Dictionary) -> Dictionary:
var page_url: String = game.get("url", "") var page_url: String = game.get("url", "")
if page_url.is_empty(): if page_url.is_empty():
logger.warn("No page URL in game dict")
return {} return {}
var http := HTTPClient.new() logger.info("Scraping page for screenshots: " + page_url)
var parts := page_url.split("/") var body := await _download_url_bytes(page_url)
var use_tls := parts[0] == "https:" if body.is_empty():
var err := http.connect_to_host(parts[2], 443 if use_tls else 80, TLSOptions.client() if use_tls else null)
if err != OK:
return {} return {}
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 {}
OS.delay_msec(50)
if http.get_status() != HTTPClient.STATUS_CONNECTED:
http.close()
return {}
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 {}
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 {}
OS.delay_msec(10)
continue
body.append_array(chunk)
http.close()
var html := body.get_string_from_utf8() var html := body.get_string_from_utf8()
if html.is_empty(): if html.is_empty():
logger.warn("Empty HTML response for: " + page_url)
return {} return {}
if _is_cloudflare_challenge(html):
logger.warn("Cloudflare challenge detected for: %s — falling back to cover art" % page_url)
return {}
return _extract_screenshots_from_html(html, page_url)
func _is_cloudflare_challenge(html: String) -> bool:
return html.find("challenge-platform") != -1 or html.find("cf-browser-verification") != -1
func _extract_screenshots_from_html(html: String, page_url: String) -> Dictionary:
var shot_re := RegEx.new() var shot_re := RegEx.new()
shot_re.compile("https://img\\.itch\\.zone/aW1hZ2Uv[^\"]*/original/[^\"]*") shot_re.compile(SCREENSHOT_REGEX_PATTERN)
var screenshots: Array = [] var screenshots: Array = []
for m in shot_re.search_all(html): for m in shot_re.search_all(html):
var url: String = m.get_string(0) var url: String = _sanitize_url(m.get_string(0))
if url.to_lower().ends_with(".gif"):
logger.debug("Skipping GIF screenshot: " + url)
continue
screenshots.append(url) screenshots.append(url)
logger.info("Found %d screenshots for page %s" % [screenshots.size(), page_url])
return {"screenshots": screenshots} return {"screenshots": screenshots}
func _load_enriched_meta() -> Dictionary: func _load_enriched_meta() -> Dictionary:
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
if typeof(raw) != TYPE_DICTIONARY: if typeof(raw) != TYPE_DICTIONARY:
return {} return {}
var out := {} var out := {}
@ -220,8 +511,8 @@ func _load_enriched_meta() -> Dictionary:
func _save_enriched_meta(game_id: int, meta: Dictionary) -> void: func _save_enriched_meta(game_id: int, meta: Dictionary) -> void:
var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) var raw: Variant = Cache.get_json(_meta_cache_dir, META_CACHE_FILE)
if typeof(raw) != TYPE_DICTIONARY: if typeof(raw) != TYPE_DICTIONARY:
raw = {} raw = {}
raw[str(game_id)] = meta raw[str(game_id)] = meta
Cache.save_json(_cache_dir, _meta_cache_file, raw) Cache.save_json(_meta_cache_dir, META_CACHE_FILE, raw)

View file

@ -10,6 +10,6 @@ var icon := preload("res://plugins/itch-artprovider/assets/itch.svg")
func _ready() -> void: func _ready() -> void:
logger = Log.get_logger("ItchArt", Log.LEVEL.INFO) logger = Log.get_logger("ItchArt", Log.LEVEL.INFO)
var boxart: BoxArtProvider = load("res://plugins/itch-artprovider/core/boxart_itch.tscn").instantiate() var boxart_provider: BoxArtProvider = load("res://plugins/itch-artprovider/core/boxart_itch.tscn").instantiate()
add_child(boxart) add_child(boxart_provider)
logger.info("itch.io Art Provider loaded") logger.info("itch.io Art Provider loaded")

View file

@ -1,7 +1,7 @@
{ {
"plugin.id": "itch-artprovider", "plugin.id": "itch-artprovider",
"plugin.name": "itch.io Art Provider", "plugin.name": "itch.io Art Provider",
"plugin.version": "0.1.2", "plugin.version": "0.1.20",
"plugin.min-api-version": "1.1.0", "plugin.min-api-version": "1.1.0",
"plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin", "plugin.link": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",
"plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin", "plugin.source": "https://forge.thergic.ar/jose/itchio-opengamepadui-artprovider-plugin",