fix: critical bugs from code review
All checks were successful
Build plugin / build (push) Successful in 1m6s
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
This commit is contained in:
parent
a54c90f7c0
commit
2ed80ec8a1
1 changed files with 77 additions and 42 deletions
|
|
@ -29,6 +29,7 @@ var _meta_cache_dir := "itch_art"
|
|||
var _enriched_meta: Dictionary = {}
|
||||
var settings_manager := load("res://core/global/settings_manager.tres") as SettingsManager
|
||||
var _animated_texture_cache: Dictionary = {}
|
||||
var _pending_gif_requests: Dictionary = {}
|
||||
var _last_animated_setting: bool = false
|
||||
var _focused_animated_texture: AnimatedTexture = null
|
||||
|
||||
|
|
@ -69,6 +70,7 @@ func _is_animated_gifs_enabled() -> bool:
|
|||
func _on_focus_changed(control: Control) -> void:
|
||||
# Pause the previously focused animation.
|
||||
if _focused_animated_texture != null:
|
||||
if is_instance_valid(_focused_animated_texture):
|
||||
_focused_animated_texture.pause = true
|
||||
_focused_animated_texture = null
|
||||
|
||||
|
|
@ -78,6 +80,8 @@ func _on_focus_changed(control: Control) -> void:
|
|||
# Walk up the tree to find the GameCard parent.
|
||||
var card: Control = control
|
||||
while card != null:
|
||||
if not is_instance_valid(card):
|
||||
return
|
||||
if card is GameCard:
|
||||
break
|
||||
card = card.get_parent()
|
||||
|
|
@ -132,9 +136,37 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
|||
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 := await _enrich_metadata(game, game_id, title)
|
||||
var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
|
||||
var cover_url := _sanitize_url(_best_portrait_url(game, 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 screenshots.size() >= 2:
|
||||
return await _fetch_banner_textures(item, screenshots, 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.
|
||||
## Uses cached results when available.
|
||||
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", "")])
|
||||
|
|
@ -150,20 +182,12 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
|||
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
|
||||
|
||||
var screenshots: Array = meta.get("screenshots", []).map(_sanitize_url)
|
||||
var cover_url := _sanitize_url(_best_portrait_url(game, 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 screenshots.size() >= 2:
|
||||
## Fetches two screenshots and combines them side-by-side for banner layout.
|
||||
## Falls back to individual screenshots if the combine fails.
|
||||
func _fetch_banner_textures(item: LibraryItem, screenshots: Array, cache_flags: int) -> Texture2D:
|
||||
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)
|
||||
|
|
@ -175,18 +199,8 @@ func get_boxart(item: LibraryItem, kind: LAYOUT) -> Texture2D:
|
|||
return tex_a
|
||||
if tex_b != null:
|
||||
return tex_b
|
||||
|
||||
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
|
||||
|
||||
|
||||
## Finds the itch.io game dict from the library item's launch items.
|
||||
func _find_game_dict(item: LibraryItem) -> Dictionary:
|
||||
|
|
@ -298,6 +312,7 @@ func _build_ffmpeg_url(os_name: String, arch_name: String) -> String:
|
|||
## 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 # HTTPRequest uses seconds.
|
||||
add_child.call_deferred(http)
|
||||
await http.ready
|
||||
|
||||
|
|
@ -372,14 +387,30 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
|
|||
if _animated_texture_cache.has(url):
|
||||
return _animated_texture_cache[url]
|
||||
|
||||
# If another request for the same URL is in progress, wait for it.
|
||||
if _pending_gif_requests.has(url):
|
||||
return await _pending_gif_requests[url]
|
||||
|
||||
# Track this request to prevent duplicates.
|
||||
_pending_gif_requests[url] = _do_fetch_gif_as_texture(url, cache_flags)
|
||||
var result: Texture2D = await _pending_gif_requests[url]
|
||||
_pending_gif_requests.erase(url)
|
||||
return result
|
||||
|
||||
|
||||
## Internal implementation of GIF fetching. Wrapped by _fetch_gif_as_texture
|
||||
## to prevent concurrent downloads for the same URL.
|
||||
func _do_fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
|
||||
|
||||
var animated: bool = _is_animated_gifs_enabled()
|
||||
var ffmpeg_bin := await _ensure_ffmpeg()
|
||||
if ffmpeg_bin.is_empty():
|
||||
return null
|
||||
|
||||
var globalized_dir := ProjectSettings.globalize_path(BOXART_DIR)
|
||||
var gif_path := globalized_dir + "/_tmp.gif"
|
||||
var frames_dir := globalized_dir + "/_tmp_frames"
|
||||
var url_hash := url.sha256_text().left(16)
|
||||
var gif_path := globalized_dir + "/_tmp_%s.gif" % url_hash
|
||||
var frames_dir := globalized_dir + "/_tmp_%s_frames" % url_hash
|
||||
|
||||
# Download the GIF bytes via HTTPClient.
|
||||
var body := await _download_gif_bytes(url)
|
||||
|
|
@ -399,7 +430,7 @@ func _fetch_gif_as_texture(url: String, cache_flags: int) -> Texture2D:
|
|||
if animated:
|
||||
result = await _build_animated_gif_texture(ffmpeg_bin, gif_path, frames_dir, body, url)
|
||||
else:
|
||||
result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp.png", url)
|
||||
result = await _build_static_gif_texture(ffmpeg_bin, gif_path, globalized_dir + "/_tmp_%s.png" % url_hash, url)
|
||||
|
||||
# Clean up.
|
||||
DirAccess.remove_absolute(gif_path)
|
||||
|
|
@ -529,6 +560,8 @@ func _parse_gif_frame_delays(data: PackedByteArray) -> Array:
|
|||
## Skips the GIF header, Logical Screen Descriptor, and Global Color Table.
|
||||
## Returns the byte offset past all header data.
|
||||
func _skip_gif_header(data: PackedByteArray) -> int:
|
||||
if data.size() < 13:
|
||||
return data.size()
|
||||
var pos := 13 # Header (6) + Logical Screen Descriptor (7).
|
||||
var packed_byte: int = data[10]
|
||||
if packed_byte & 0x80 != 0:
|
||||
|
|
@ -540,9 +573,11 @@ func _skip_gif_header(data: PackedByteArray) -> int:
|
|||
## Parses an extension block starting at `pos`. Returns [delay_ms, new_pos].
|
||||
## If the extension is not a Graphics Control Extension, returns [-1, new_pos].
|
||||
func _parse_gif_extension(data: PackedByteArray, pos: int) -> Array:
|
||||
if pos + 2 >= data.size():
|
||||
return [-1, data.size()]
|
||||
var label: int = data[pos + 1]
|
||||
var delay_ms := -1
|
||||
if label == 0xF9:
|
||||
if label == 0xF9 and pos + 5 < data.size():
|
||||
var delay_cs: int = data[pos + 4] | (data[pos + 5] << 8)
|
||||
delay_ms = delay_cs * 10 if delay_cs > 0 else GIF_DEFAULT_FRAME_DELAY_MS
|
||||
pos += 2
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue