Remove boxart download system
All checks were successful
Build plugin / build (push) Successful in 1m11s
All checks were successful
Build plugin / build (push) Successful in 1m11s
This commit is contained in:
parent
dbd955437e
commit
f4aa8fd1e0
1 changed files with 0 additions and 281 deletions
|
|
@ -19,13 +19,6 @@ var _diagnosed_pids := {}
|
||||||
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
## caveId -> true when butlerd reports an update for that cave. Refreshed in the
|
||||||
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
## background by CheckUpdate so has_update() stays cheap and synchronous.
|
||||||
var _update_flags := {}
|
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:
|
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:
|
func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant:
|
||||||
if game.is_empty():
|
if game.is_empty():
|
||||||
return null
|
return null
|
||||||
game = _apply_enriched(game)
|
|
||||||
if game.get("classification", "game") != "game":
|
if game.get("classification", "game") != "game":
|
||||||
return null
|
return null
|
||||||
var game_id: int = int(game.get("id", 0))
|
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
|
## from Fetch.Caves. Uses the standard Cache system so we don't hammer
|
||||||
## butlerd (and, transitively, the itch.io API) on every library refresh.
|
## 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]:
|
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):
|
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)
|
var json_items = Cache.get_json(_cache_dir, _apps_cache_file)
|
||||||
if json_items != null:
|
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]
|
var items := [] as Array[LibraryLaunchItem]
|
||||||
for i in json_items:
|
for i in json_items:
|
||||||
var item := LibraryLaunchItem.from_dict(i)
|
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 game: Dictionary = item.metadata.get("game", {})
|
||||||
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
var cave: Dictionary = caves_by_game_id.get(int(game.get("id", 0)), {})
|
||||||
if not cave.is_empty() and _cave_has_files(cave):
|
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):
|
if not _game_available_on_current_platform(game, item.installed):
|
||||||
continue
|
continue
|
||||||
items.append(item)
|
items.append(item)
|
||||||
_queue_boxart(items)
|
|
||||||
if caching_flags & Cache.FLAGS.SAVE:
|
if caching_flags & Cache.FLAGS.SAVE:
|
||||||
var json_out := []
|
var json_out := []
|
||||||
for it in items:
|
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:
|
if Cache.save_json(_cache_dir, _apps_cache_file, json_items) != OK:
|
||||||
logger.warn("Unable to save itch.io apps cache")
|
logger.warn("Unable to save itch.io apps cache")
|
||||||
|
|
||||||
_queue_boxart(items)
|
|
||||||
|
|
||||||
return items
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -642,271 +626,6 @@ func _cleanup_orphan_caves(cave_ids: Array) -> void:
|
||||||
await itch.uninstall(cave_id)
|
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 <game name>-<layout>.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('<tr><td>Genre</td><td><a[^>]*>([^<]+)</a>')
|
|
||||||
var inputs_row_re := RegEx.new()
|
|
||||||
inputs_row_re.compile('(?s)<tr><td>Inputs</td><td>(.*?)</td></tr>')
|
|
||||||
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.
|
## Resolves the authoritative launch command for an installed game.
|
||||||
##
|
##
|
||||||
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
|
## butlerd writes .itch/receipt.json.gz into every install folder and resolves
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue