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.
518 lines
18 KiB
GDScript
518 lines
18 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 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
|
|
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",
|
|
}
|
|
|
|
## Resolved path to the ffmpeg binary (system or bundled). Populated lazily.
|
|
var _ffmpeg_bin: String = ""
|
|
|
|
|
|
func _init() -> void:
|
|
super()
|
|
var globalized := ProjectSettings.globalize_path(BOXART_DIR)
|
|
DirAccess.make_dir_recursive_absolute(globalized)
|
|
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".
|
|
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:
|
|
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
|
|
|
|
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
|
|
|
|
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.
|
|
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:
|
|
var stitched := _combine_side_by_side(tex_a, tex_b)
|
|
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:
|
|
return tex_a
|
|
if tex_b != null:
|
|
return tex_b
|
|
return null
|
|
|
|
|
|
## 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:
|
|
return screenshots[0] if not screenshots.is_empty() else cover_url
|
|
LAYOUT.BANNER:
|
|
return screenshots[0] if not screenshots.is_empty() else cover_url
|
|
return ""
|
|
|
|
|
|
## 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", "")
|
|
if not still.is_empty():
|
|
return still
|
|
return game.get("coverUrl", "")
|
|
|
|
|
|
## Ensures an ffmpeg binary is available. Checks the system PATH first,
|
|
## then falls back to a bundled copy under user://boxart/itch/.
|
|
func _ensure_ffmpeg() -> String:
|
|
if not _ffmpeg_bin.is_empty():
|
|
return _ffmpeg_bin
|
|
var probe: Array = []
|
|
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
|
|
|
|
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.
|
|
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 body := await _download_url_bytes(page_url)
|
|
if body.is_empty():
|
|
return {}
|
|
var html := body.get_string_from_utf8()
|
|
if html.is_empty():
|
|
logger.warn("Empty HTML response for: " + page_url)
|
|
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()
|
|
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))
|
|
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)
|