From e9f25d104bf8d0119f5059ae9694b254098b71cf Mon Sep 17 00:00:00 2001 From: Jose Falanga Date: Thu, 6 Aug 2026 18:52:55 -0300 Subject: [PATCH] Merge games from the profile's itch.io collections into the library (deduped against owned games) so bookmarked free games show up --- core/itch_client.gd | 62 ++++++++++++++++++++++++++++++++++++++++++ core/library_itch.gd | 65 +++++++++++++++++++++++++++++--------------- plugin.json | 2 +- 3 files changed, 106 insertions(+), 23 deletions(-) diff --git a/core/itch_client.gd b/core/itch_client.gd index 2958cf8..eb5f1de 100644 --- a/core/itch_client.gd +++ b/core/itch_client.gd @@ -387,6 +387,68 @@ func _get_owned_games() -> Array: return res.get("items", []) +## Returns every game present in any of the profile's collections, as full +## Game dicts (CollectionGame embeds the whole game object). These aren't +## necessarily owned: the user may have bookmarked free games in a collection. +func get_collection_games() -> Array: + return await thread_group.exec(_get_collection_games) + + +func _get_collection_games() -> Array: + if not is_logged_in: + return [] + var params := {"profileId": profile.get("user", {}).get("id", profile.get("id", 0))} + var res := await _rpc_call("Fetch.ProfileCollections", params) + if "error" in res: + logger.warn("Fetch.ProfileCollections failed: " + str(res["error"])) + return [] + if res.get("stale", false): + logger.info("Collections are stale. Refreshing from itch.io.") + params["fresh"] = true + res = await _rpc_call("Fetch.ProfileCollections", params) + if "error" in res: + logger.warn("Fetch.ProfileCollections (fresh) failed: " + str(res["error"])) + return [] + return await _fetch_collection_games( + profile.get("user", {}).get("id", profile.get("id", 0)), + res.get("items", []) + ) + + +## Paginates through Fetch.Collection.Games for every collection, collecting the +## embedded game objects. Each collection can span multiple pages (cursor), and +## a page served from butlerd's local cache is re-issued fresh. +func _fetch_collection_games(profile_id: int, collections: Array) -> Array: + var games := [] + for c in collections: + var collection: Dictionary = c + var collection_id: int = collection.get("id", 0) + var cursor: Variant = null + while true: + var params := {"profileId": profile_id, "collectionId": collection_id} + if cursor != null: + params["cursor"] = cursor + var res := await _rpc_call("Fetch.Collection.Games", params) + if "error" in res: + logger.warn("Fetch.Collection.Games failed for collection " + str(collection_id) + ": " + str(res["error"])) + break + if res.get("stale", false): + params["fresh"] = true + res = await _rpc_call("Fetch.Collection.Games", params) + if "error" in res: + logger.warn("Fetch.Collection.Games (fresh) failed for collection " + str(collection_id) + ": " + str(res["error"])) + break + for cg in res.get("items", []): + var collection_game: Dictionary = cg + var game: Dictionary = collection_game.get("game", {}) + if not game.is_empty(): + games.append(game) + cursor = res.get("nextCursor", null) + if cursor == null: + break + return games + + ## Returns every installed game (a "cave" in butlerd terminology). func get_caves() -> Array: return await thread_group.exec(_get_caves) diff --git a/core/library_itch.gd b/core/library_itch.gd index 8251a12..a8d4840 100644 --- a/core/library_itch.gd +++ b/core/library_itch.gd @@ -66,6 +66,33 @@ func _on_install_progressed(_id: String, _current: int, _total: int) -> void: pass +## Builds a LibraryLaunchItem for a game, or null when the entry isn't an +## actual game (itch.io also hosts tools, assets, soundtracks, comics, ...). +func _make_item(game: Dictionary, caves_by_game_id: Dictionary) -> Variant: + if game.is_empty(): + return null + if game.get("classification", "game") != "game": + return null + var game_id: int = game.get("id", 0) + var cave: Dictionary = caves_by_game_id.get(game_id, {}) + + var item := LibraryLaunchItem.new() + item.provider_app_id = cave.get("id", "") + item.name = game.get("title", "Unknown itch.io game") + item.tags = ["itch"] + item.categories = ["Game"] + item.installed = not cave.is_empty() + item.metadata = {"game": game} + + if not cave.is_empty(): + var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") + var exe := _find_executable(install_folder) + item.command = exe + item.args = [] + item.cwd = install_folder + return item + + ## Builds the full itch.io library: owned games merged with install state ## from Fetch.Caves. Uses the standard Cache system so we don't hammer ## butlerd (and, transitively, the itch.io API) on every library refresh. @@ -87,6 +114,7 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> logger.info("Fetching itch.io library...") var owned: Array = await itch.get_owned_games() var caves: Array = await itch.get_caves() + var collection_games: Array = await itch.get_collection_games() var caves_by_game_id := {} for c in caves: @@ -95,34 +123,27 @@ func _load_library(caching_flags: int = Cache.FLAGS.LOAD | Cache.FLAGS.SAVE) -> caves_by_game_id[game_id] = cave var items := [] as Array[LibraryLaunchItem] + var seen_game_ids := {} for o in owned: var owned_entry: Dictionary = o var game: Dictionary = owned_entry.get("game", {}) - if game.is_empty(): - continue - # Only surface actual games (itch.io also hosts tools, assets, - # soundtracks, comics, etc. under the same ownership API). - if game.get("classification", "game") != "game": + var item := _make_item(game, caves_by_game_id) + if item == null: continue + seen_game_ids[game.get("id", 0)] = true + items.append(item) + # Collection games aren't necessarily owned (e.g. a free game the user + # bookmarked), so merge them in too, deduped against owned games. + for g in collection_games: + var game: Dictionary = g var game_id: int = game.get("id", 0) - var cave: Dictionary = caves_by_game_id.get(game_id, {}) - - var item := LibraryLaunchItem.new() - item.provider_app_id = cave.get("id", "") - item.name = game.get("title", "Unknown itch.io game") - item.tags = ["itch"] - item.categories = ["Game"] - item.installed = not cave.is_empty() - item.metadata = {"game": game} - - if not cave.is_empty(): - var install_folder: String = cave.get("installInfo", {}).get("installFolder", "") - var exe := _find_executable(install_folder) - item.command = exe - item.args = [] - item.cwd = install_folder - + if game_id in seen_game_ids: + continue + var item := _make_item(game, caves_by_game_id) + if item == null: + continue + seen_game_ids[game_id] = true items.append(item) if caching_flags & Cache.FLAGS.SAVE: diff --git a/plugin.json b/plugin.json index 662eb5a..293ce98 100644 --- a/plugin.json +++ b/plugin.json @@ -1,7 +1,7 @@ { "plugin.id": "itch", "plugin.name": "itch.io", - "plugin.version": "0.1.5", + "plugin.version": "0.1.6", "plugin.min-api-version": "1.1.0", "plugin.link": "https://github.com/YOUR_USERNAME/OpenGamepadUI-itch", "plugin.source": "https://github.com/YOUR_USERNAME/OpenGamepadUI-itch",