diff --git a/core/library_itch.gd b/core/library_itch.gd index 8100989..09d8530 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -19,13 +19,6 @@ var _diagnosed_pids := {} ## caveId -> true when butlerd reports an update for that cave. Refreshed in the ## background by CheckUpdate so has_update() stays cheap and synchronous. var _update_flags := {} -## gameId -> {genre, inputs, screenshots} parsed from each game's itch.io page. -## butlerd's Game struct has none of these fields, so they're cached here and -## merged into the game dict on every library load, powering per-layout boxart -## (and, potentially, gamepad/genre filters). -var _enriched: Dictionary = {} - -const _meta_cache_file: String = "itch_meta.json" func _ready() -> void: @@ -456,7 +449,6 @@ func _cave_has_files(cave: Dictionary) -> bool: func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: if game.is_empty(): return null - game = _apply_enriched(game) if game.get("classification", "game") != "game": return null var game_id: int = int(game.get("id", 0)) @@ -487,7 +479,6 @@ func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: ## from Fetch.Caves. Uses the standard Cache system so we don't hammer ## butlerd (and, transitively, the itch.io API) on every library refresh. func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> Array[LibraryLaunchItem]: - _enriched = _load_enriched_meta() if caching_flags & Cache.FLAGS.LOAD and Cache.is_cached(_cache_dir, _apps_cache_file): var json_items = Cache.get_json(_cache_dir, _apps_cache_file) if json_items != null: @@ -526,10 +517,6 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> var items := [] as Array[LibraryLaunchItem] for i in json_items: var item := LibraryLaunchItem.from_dict(i) - # Re-apply enriched page metadata (genre/inputs/screenshots) from - # the itch_meta cache so a cached library still writes per-layout - # boxart (and could filter by gamepad/genre) on cold loads. - item.metadata["game"] = _apply_enriched(item.metadata.get("game", {})) var game: Dictionary = item.metadata.get("game", {}) var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {}) if not cave.is_empty() and _cave_has_files(cave): @@ -557,7 +544,6 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> if not _game_available_on_current_platform(game, item.installed): continue items.append(item) - _queue_boxart(items) if caching_flags & Cache.FLAGS.SAVE: var json_out := [] for it in items: @@ -629,8 +615,6 @@ 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 @@ -642,271 +626,6 @@ func _cleanup_orphan_caves(cave_ids: Array) -> void: await itch.uninstall(cave_id) -## Queues background downloads of each game's 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. Each task enriches the game from its itch.io page -## first (genre/inputs/screenshots, cached in itch_meta.json) and then writes -## per-layout art: the cover for portrait/logo, full-res screenshots for -## landscape/banner. For games with an animated GIF cover, itch.io provides a -## stillCoverUrl (a static frame, served as PNG) which we prefer, since Godot -## has no GIF decoder. -func _queue_boxart(items: Array) -> void: - for i in items: - var item: LibraryLaunchItem = i - var game: Dictionary = item.metadata.get("game", {}) - if int(game.get("id", 0)) == 0: - continue - itch.thread_group.scheduled_exec(_enrich_and_boxart.bind(game), 0) - - -## Downloads the given game's art (blocking, runs on the shared thread) and -## writes it to the boxart layouts. The cover (or a full-res screenshot when -## enriched metadata is available) fills every slot itch.io can serve: OGPU's -## boxart slots all keep-aspect (scale + crop), so the 315x250 cover works for -## portrait/logo while screenshots give landscape/banner real resolution. -## When force is set (right after a fresh page fetch), landscape/banner get -## overwritten so installs that only ever had the cover upgraded with art. -func _ensure_boxart(game: Dictionary, meta: Dictionary = {}, force := false) -> void: - var title: String = game.get("title", "") - var cover_url := _cover_url(game) - if title.is_empty() or cover_url.is_empty(): - return - var dir := _boxart_dir() - - # Screenshots are full-res originals from the game page; the cover is only - # reused for landscape/banner when a game has no screenshots at all. - var screenshots: Array = meta.get("screenshots", []) - var sources := { - "portrait": cover_url, - "landscape": screenshots[0] if not screenshots.is_empty() else cover_url, - "banner": screenshots[1] if screenshots.size() > 1 else (screenshots[0] if not screenshots.is_empty() else cover_url), - "logo": cover_url, - } - # portrait/logo keep the cover forever; landscape/banner are replaced by - # screenshot art on the first enrichment after an upgrade. - var fixed := {"portrait": true, "logo": true} - var bodies := {} - DirAccess.make_dir_recursive_absolute(dir) - for layout in ["portrait", "landscape", "banner", "logo"]: - var path := "/".join([dir, title + "-" + layout + ".png"]) - if FileAccess.file_exists(path): - if fixed.has(layout) or not force: - continue - var url: String = sources[layout] - if url.is_empty(): - continue - var body: PackedByteArray - if bodies.has(url): - body = bodies[url] - else: - body = _http_get(url) - bodies[url] = body - if body.is_empty() or not _is_raster_image(body): - # OGPU's local provider only loads png/jpg. If itch.io didn't give us a - # still for an animated cover, skip rather than write an unloadable file. - logger.warn("Skipping unsupported art for '" + title + "' layout " + layout) - continue - var file := FileAccess.open(path, FileAccess.WRITE) - if file: - file.store_buffer(body) - file.close() - logger.info("Downloaded " + layout + " boxart for '" + title + "'") - - -## Runs on the shared thread: enrich the game from its itch.io page (once per -## game, cached in itch_meta.json) and write per-layout boxart. A fresh fetch -## force-refreshes landscape/banner so installs that only ever had the cover -## copied into every slot get upgraded to full-res screenshots. -func _enrich_and_boxart(game: Dictionary) -> void: - var game_id := int(game.get("id", 0)) - if game_id == 0: - return - var meta := {} - var force := false - if _enriched.has(game_id): - meta = _enriched[game_id] - else: - var fetched := _fetch_game_page_metadata(game) - if not fetched.is_empty(): - meta = fetched - force = true - _save_enriched_meta(game_id, meta) - logger.info("Enriched page metadata for '" + game.get("title", "") + "': genre='" + str(meta.get("genre", "")) + "' inputs=" + str(meta.get("inputs", []))) - _ensure_boxart(game, meta, force) - - -## Fetches a game's itch.io page and parses the Details table (genre, inputs) -## plus the screenshot gallery. Returns {} on failure, otherwise -## {genre: String, inputs: Array[String], screenshots: Array[String]}. None of -## these fields exist in butlerd's Game struct; data.json exposes only freeform -## tags and a price, so the page HTML is the only keyless source. -func _fetch_game_page_metadata(game: Dictionary) -> Dictionary: - var page_url: String = game.get("url", "") - if page_url.is_empty(): - return {} - var body := _http_get(page_url) - if body.is_empty(): - return {} - var html := body.get_string_from_utf8() - if html.is_empty(): - return {} - - # RegEx instances are created here (background thread) to avoid sharing - # RegEx objects across threads. - var genre_re := RegEx.new() - genre_re.compile('Genre]*>([^<]+)') - var inputs_row_re := RegEx.new() - inputs_row_re.compile('(?s)Inputs(.*?)') - var input_slug_re := RegEx.new() - input_slug_re.compile('input-([a-z0-9-]+)') - var shot_re := RegEx.new() - shot_re.compile('https://img\\.itch\\.zone/aW1hZ2Uv[^"]*/original/[^"]*') - - var genre := "" - var genre_match := genre_re.search(html) - if genre_match != null: - genre = _decode_html(genre_match.get_string(1).strip_edges()) - - var inputs: Array = [] - var inputs_match := inputs_row_re.search(html) - if inputs_match != null: - for m in input_slug_re.search_all(inputs_match.get_string(1)): - inputs.append(m.get_string(1)) - - var screenshots: Array = [] - for m in shot_re.search_all(html): - screenshots.append(m.get_string(0)) - - return {"genre": genre, "inputs": inputs, "screenshots": screenshots} - - -## Decodes the HTML entities itch.io's Details table can contain. -func _decode_html(value: String) -> String: - value = value.replace("&", "&") - value = value.replace("<", "<") - value = value.replace(">", ">") - value = value.replace(""", "\"") - value = value.replace("'", "'") - return value - - -## Loads the itch_meta.json cache: gameId -> {genre, inputs, screenshots}. -## JSON round-trips integer keys as strings, so they are normalized to int. -func _load_enriched_meta() -> Dictionary: - var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) - if typeof(raw) != TYPE_DICTIONARY: - return {} - var out := {} - for key in raw: - out[int(key)] = raw[key] - return out - - -## Merges one game's enriched metadata into the shared itch_meta.json cache. -func _save_enriched_meta(game_id: int, meta: Dictionary) -> void: - var raw: Variant = Cache.get_json(_cache_dir, _meta_cache_file) - if typeof(raw) != TYPE_DICTIONARY: - raw = {} - raw[str(game_id)] = meta - Cache.save_json(_cache_dir, _meta_cache_file, raw) - - -## Merges the enriched metadata (genre/inputs/screenshots) into a game dict so -## it survives and is available during _make_item and boxart setup. -func _apply_enriched(game: Dictionary) -> Dictionary: - if game.is_empty(): - return game - var meta: Dictionary = _enriched.get(int(game.get("id", 0)), {}) - if meta.is_empty(): - return game - if not game.has("genre"): - game["genre"] = meta.get("genre", "") - if not game.has("inputs"): - game["inputs"] = meta.get("inputs", []) - if not game.has("screenshots"): - game["screenshots"] = meta.get("screenshots", []) - return game - - -func _boxart_dir() -> String: - return ProjectSettings.globalize_path("user://boxart/local") - - -## itch.io exposes stillCoverUrl for games whose cover is an animated GIF: a -## static frame that the CDN serves as PNG. Prefer it over coverUrl so those -## games still get art (Godot can't decode GIFs at runtime). -func _cover_url(game: Dictionary) -> String: - var still: String = game.get("stillCoverUrl", "") - if not still.is_empty(): - return still - return game.get("coverUrl", "") - - -## Blocking HTTPS GET of a single URL (cover image or game page HTML), for use -## on a background thread. -func _http_get(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 - - ## Resolves the authoritative launch command for an installed game. ## ## butlerd writes .itch/receipt.json.gz into every install folder and resolves