From be384ca2a2c5409db0e46f660c94524c0807092b Mon Sep 17 00:00:00 2001 From: ovurrsl Date: Sat, 17 Oct 2026 08:52:01 +0300 Subject: [PATCH] feat: games that already have a cover still get a logo and icon The automatic art fill no longer skips a game just because its folder has a cover. Store games and added games with their own cover now get a missing logo (the store's own logo first, then Steam, then SteamGridDB) and a missing icon from SteamGridDB. Existing, folder and chosen art is never replaced; a slot that stays empty is asked about again after a week, as before. --- .../launcher/frontend/AddedGameArt.kt | 110 +++++++++--- .../launcher/frontend/SteamGridDb.kt | 23 ++- .../launcher/frontend/AddedGameArtFillTest.kt | 163 ++++++++++++++++++ 3 files changed, 260 insertions(+), 36 deletions(-) create mode 100644 app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt index b3bdeb968..0c5ca7b98 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/AddedGameArt.kt @@ -5,6 +5,8 @@ import android.graphics.Bitmap import android.util.Log import com.droiddeck.launcher.files.PeIconExtractor import com.droiddeck.launcher.session.SessionPrefs +import com.droiddeck.launcher.stores.StoreArt +import com.droiddeck.launcher.stores.StoreGameSidecar import org.json.JSONObject import java.io.File import java.net.HttpURLConnection @@ -228,41 +230,93 @@ object AddedGameArt { return if (tmp.renameTo(png)) png else null } - fun fetchMissing(context: Context, games: List): Boolean { + internal class Sources( + val storeLogo: (AddedGames.Game) -> Boolean, + val steamSearch: (String) -> Int?, + val steam: (String, File, Set) -> Boolean, + val sgdbGame: (String, Int?, String) -> Int?, + val sgdb: (String, Int, File, Set) -> Boolean, + ) + + private val network = Sources( + storeLogo = ::storeLogo, + steamSearch = { name -> search(name)?.toIntOrNull() }, + steam = ::download, + sgdbGame = SteamGridDb::gameId, + sgdb = SteamGridDb::download, + ) + + private val EXTRAS = setOf(Slot.LOGO, Slot.ICON) + private val STEAM_SLOTS = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO) + internal const val EXTRAS_TRIED = "extras-tried" + + internal fun missing(context: Context, game: AddedGames.Game): Set { + val own = local(game) + val cache = cacheDir(context, game) + fun has(slot: Slot, mine: File?, cached: String) = chosen(cache, slot) != null || mine != null || File(cache, cached).isFile + return Slot.values().filterNot { slot -> + when (slot) { + Slot.COVER -> has(slot, own.portrait, "p.jpg") + Slot.WIDE -> has(slot, own.header, "header.jpg") + Slot.BACKGROUND -> has(slot, own.hero, "hero.jpg") + Slot.LOGO -> has(slot, own.logo, "logo.png") + Slot.ICON -> has(slot, own.icon, "icon-sgdb.png") + } + }.toSet() + } + + private fun storeLogo(game: AddedGames.Game): Boolean { + val url = StoreGameSidecar.read(game.folder)?.logo ?: return false + return StoreArt.fetchLogo(game.folder, url) + } + + fun fetchMissing(context: Context, games: List): Boolean = + fetchMissing(context, games, SteamGridDb.key(context), network) + + internal fun fetchMissing(context: Context, games: List, sgdbKey: String, sources: Sources): Boolean { if (!SessionPrefs.addedGamesArt(context)) return false - val sgdbKey = SteamGridDb.key(context) val keyChangedAt = SteamGridDb.keyChangedAt(context) var sgdbDown = false var changed = false for (game in games) { - if (local(game).portrait != null) continue + val start = missing(context, game) + val full = Slot.COVER in start + val allowed = if (full) Slot.values().toSet() else EXTRAS + var need = start intersect allowed + if (need.isEmpty() || (need == setOf(Slot.ICON) && sgdbKey.isEmpty())) continue val cache = cacheDir(context, game) - if (File(cache, "p.jpg").isFile) continue + val tried = File(cache, EXTRAS_TRIED) + if (!full && tried.isFile && tried.lastModified() >= keyChangedAt && System.currentTimeMillis() - tried.lastModified() < RETRY_AFTER_MS) continue cache.mkdirs() - val match = steamMatch(context, game) { name -> - runCatching { search(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull()?.toIntOrNull() + if (Slot.LOGO in need && game.source != Library.ADDED && runCatching { sources.storeLogo(game) }.getOrDefault(false)) { + changed = true + need = need - Slot.LOGO } - val appId = match.appId?.takeIf { match.certainty == SteamMatch.Certainty.FILES } - val id = match.appId?.toString() - if (id != null && download(id, cache)) changed = true - if (File(cache, "p.jpg").isFile || sgdbKey.isEmpty() || sgdbDown) continue - val stored: String? = try { - remembered(File(cache, "sgdb-id"), keyChangedAt) { - SteamGridDb.gameId(sgdbKey, appId ?: id?.toIntOrNull(), game.name)?.toString() + val match by lazy { + steamMatch(context, game) { name -> + runCatching { sources.steamSearch(name) }.onFailure { Log.w(TAG, "${game.name}: store search failed (${it.message})") }.getOrNull() } - } catch (e: Exception) { - Log.w(TAG, "${game.name}: SteamGridDB lookup failed (${e.javaClass.simpleName}); not asking again this pass") - sgdbDown = true - null } - val gridId = stored?.toIntOrNull() ?: continue - val grabbed = try { - SteamGridDb.download(sgdbKey, gridId, cache) - } catch (e: Exception) { - sgdbDown = true - false + val steamWant = need intersect STEAM_SLOTS + if (steamWant.isNotEmpty()) { + val id = match.appId?.toString() + if (id != null && sources.steam(id, cache, steamWant)) changed = true + need = missing(context, game) intersect allowed } - if (grabbed) changed = true + var asked = true + if (need.isNotEmpty() && sgdbKey.isNotEmpty()) { + if (sgdbDown) asked = false else try { + val gridId = remembered(File(cache, "sgdb-id"), keyChangedAt) { + sources.sgdbGame(sgdbKey, match.appId, game.name)?.toString() + }?.toIntOrNull() + if (gridId != null && sources.sgdb(sgdbKey, gridId, cache, need)) changed = true + } catch (e: Exception) { + Log.w(TAG, "${game.name}: SteamGridDB failed (${e.javaClass.simpleName}); not asking again this pass") + sgdbDown = true + asked = false + } + } + if ((missing(context, game) intersect EXTRAS).isEmpty()) tried.delete() else if (asked) tried.writeText("") } return changed } @@ -314,11 +368,13 @@ object AddedGameArt { return pickStoreResult(name, (0 until items.length()).map { items.getJSONObject(it) }.map { it.optString("id") to it.optString("name") }) } - private fun download(id: String, cache: File): Boolean { + private fun download(id: String, cache: File, slots: Set): Boolean { var got = false - for ((remote, localName) in listOf( - "library_600x900.jpg" to "p.jpg", "header.jpg" to "header.jpg", "library_hero.jpg" to "hero.jpg", "logo.png" to "logo.png", + for ((slot, remote, localName) in listOf( + Triple(Slot.COVER, "library_600x900.jpg", "p.jpg"), Triple(Slot.WIDE, "header.jpg", "header.jpg"), + Triple(Slot.BACKGROUND, "library_hero.jpg", "hero.jpg"), Triple(Slot.LOGO, "logo.png", "logo.png"), )) { + if (slot !in slots) continue val dst = File(cache, localName) if (dst.isFile) continue val bytes = get("$CDN$id/$remote") ?: steamAssetUrl(id, remote)?.let { get(it) } ?: continue diff --git a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt index dec6c24cf..2eaa89410 100644 --- a/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt +++ b/app/src/main/java/com/droiddeck/launcher/frontend/SteamGridDb.kt @@ -84,18 +84,23 @@ object SteamGridDb { .filter { it.second > 0 && it.first > 0 } .maxByOrNull { it.second }?.first - fun download(key: String, id: Int, cache: File): Boolean { + internal fun cacheName(slot: AddedGameArt.Slot): String = + when (slot) { + AddedGameArt.Slot.COVER -> "p.jpg" + AddedGameArt.Slot.WIDE -> "header.jpg" + AddedGameArt.Slot.BACKGROUND -> "hero.jpg" + AddedGameArt.Slot.LOGO -> "logo.png" + AddedGameArt.Slot.ICON -> "icon-sgdb.png" + } + + fun download(key: String, id: Int, cache: File, slots: Set = AddedGameArt.Slot.values().toSet()): Boolean { var got = false - for ((path, local) in listOf( - "grids/game/$id?dimensions=600x900&$QUERY" to "p.jpg", - "grids/game/$id?dimensions=460x215,920x430&$QUERY" to "header.jpg", - "heroes/game/$id?$QUERY" to "hero.jpg", - "logos/game/$id?$QUERY" to "logo.png", - "icons/game/$id?$QUERY" to "icon-sgdb.png", - )) { + for (slot in AddedGameArt.Slot.values()) { + if (slot !in slots) continue + val local = cacheName(slot) val dst = File(cache, local) if (dst.isFile) continue - val items = json(key, "$API/$path")?.optJSONArray("data") ?: continue + val items = json(key, "$API/${path(id, slot)}")?.optJSONArray("data") ?: continue val url = best(items, local.endsWith(".png")) ?: continue val bytes = AddedGameArt.get(url) ?: continue val tmp = File(cache, "$local.tmp") diff --git a/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt new file mode 100644 index 000000000..473c43922 --- /dev/null +++ b/app/src/test/java/com/droiddeck/launcher/frontend/AddedGameArtFillTest.kt @@ -0,0 +1,163 @@ +package com.droiddeck.launcher.frontend + +import com.droiddeck.launcher.frontend.AddedGameArt.Slot +import com.droiddeck.launcher.session.SessionPrefs +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Assert.fail +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config +import java.io.File + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [33]) +class AddedGameArtFillTest { + private val app get() = RuntimeEnvironment.getApplication() + @get:Rule val tmp = TemporaryFolder() + + private val coverBytes = byteArrayOf(9, 9, 9) + + private fun game(name: String, appId: Long, cover: Boolean = true, source: String = Library.ADDED): AddedGames.Game { + val dir = tmp.newFolder(name) + File(dir, "steam_appid.txt").writeText("504230") + if (cover) File(dir, "cover.jpg").writeBytes(coverBytes) + val exe = File(dir, "$name.exe").apply { writeText("x") } + return AddedGames.Game(dir, name, exe, "/g/$name.exe", "/g", appId, appId, emptyList(), source = source) + } + + private class Calls { + val steam = ArrayList>() + val sgdb = ArrayList>() + var lookups = 0 + var store = 0 + } + + private val steamNames = mapOf(Slot.COVER to "p.jpg", Slot.WIDE to "header.jpg", Slot.BACKGROUND to "hero.jpg", Slot.LOGO to "logo.png") + + private fun sources( + calls: Calls, steamHas: Set = emptySet(), sgdbHas: Set = emptySet(), + storeHas: Boolean = false, sgdbFails: Boolean = false, + ) = AddedGameArt.Sources( + storeLogo = { g -> + calls.store++ + if (storeHas) File(g.folder, "logo.png").writeBytes(byteArrayOf(4)) + storeHas + }, + steamSearch = { fail("searched although the game names its appid"); null }, + steam = { id, cache, slots -> + assertEquals("504230", id) + calls.steam += slots + (slots intersect steamHas).onEach { File(cache, steamNames.getValue(it)).writeBytes(byteArrayOf(1)) }.isNotEmpty() + }, + sgdbGame = { _, steamId, _ -> + calls.lookups++ + assertEquals(504230, steamId) + 77 + }, + sgdb = { _, id, cache, slots -> + assertEquals(77, id) + if (sgdbFails) throw SteamGridDb.Failed(SteamGridDb.Status.OFFLINE) + calls.sgdb += slots + (slots intersect sgdbHas).onEach { File(cache, SteamGridDb.cacheName(it)).writeBytes(byteArrayOf(2)) }.isNotEmpty() + }, + ) + + @Test fun aGameWithItsOwnCoverStillGetsItsLogoAndIcon() { + val g = game("Celeste", 0x81000001L) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(0, calls.store) + val cache = AddedGameArt.cacheDir(app, g) + assertFalse(File(cache, "p.jpg").exists()) + assertArrayEquals(coverBytes, File(g.folder, "cover.jpg").readBytes()) + val art = AddedGameArt.resolve(app, g) + assertEquals(File(g.folder, "cover.jpg"), art.portrait) + assertEquals(File(cache, "logo.png"), art.logo) + assertEquals(File(cache, "icon-sgdb.png"), art.icon) + assertFalse(File(cache, AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun filledOrChosenSlotsAreNeverAskedFor() { + val g = game("Hades", 0x81000002L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val picked = File(tmp.root, "mine.png").apply { writeBytes(byteArrayOf(6)) } + assertTrue(AddedGameArt.store(AddedGameArt.cacheDir(app, g), Slot.ICON, AddedGameArt.Option(AddedGameArt.FILE, picked.path, picked.path))) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = Slot.values().toSet(), sgdbHas = Slot.values().toSet()))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertArrayEquals(byteArrayOf(5), File(g.folder, "logo.png").readBytes()) + } + + @Test fun withoutAKeyAMissingIconAloneAsksNothing() { + val g = game("Inside", 0x81000003L) + File(g.folder, "logo.png").writeBytes(byteArrayOf(5)) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "", sources(calls))) + assertTrue(calls.steam.isEmpty() && calls.sgdb.isEmpty() && calls.lookups == 0) + assertFalse(AddedGameArt.cacheDir(app, g).exists()) + } + + @Test fun aMissIsAskedAgainOnlyAfterAWeek() { + val g = game("Limbo", 0x81000004L) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls))) + assertEquals(listOf(setOf(Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls)) + assertEquals(1, calls.steam.size) + assertEquals(1, calls.sgdb.size) + val tried = File(AddedGameArt.cacheDir(app, g), AddedGameArt.EXTRAS_TRIED) + assertTrue(tried.setLastModified(System.currentTimeMillis() - 8L * 24 * 3600 * 1000)) + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(2, calls.steam.size) + assertEquals(setOf(Slot.ICON), calls.sgdb.last()) + assertFalse(tried.exists()) + } + + @Test fun theStoresOwnLogoComesFirst() { + val g = game("Metalstorm", 0x81000005L, source = "gog") + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", sources(calls, storeHas = true, steamHas = setOf(Slot.LOGO), sgdbHas = setOf(Slot.ICON)))) + assertEquals(1, calls.store) + assertTrue(calls.steam.isEmpty()) + assertEquals(listOf(setOf(Slot.ICON)), calls.sgdb) + assertEquals(File(g.folder, "logo.png"), AddedGameArt.resolve(app, g).logo) + } + + @Test fun aGameWithoutACoverStillGetsEverySlot() { + val g = game("Tunic", 0x81000006L, cover = false) + val calls = Calls() + assertTrue(AddedGameArt.fetchMissing(app, listOf(g), "key", + sources(calls, steamHas = setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND), sgdbHas = setOf(Slot.LOGO, Slot.ICON)))) + assertEquals(listOf(setOf(Slot.COVER, Slot.WIDE, Slot.BACKGROUND, Slot.LOGO)), calls.steam) + assertEquals(listOf(setOf(Slot.LOGO, Slot.ICON)), calls.sgdb) + assertTrue(AddedGameArt.missing(app, g).isEmpty()) + } + + @Test fun aSteamGridDbFailureIsNotRememberedAndStopsThePass() { + val first = game("Gris", 0x81000007L) + val second = game("Hollow", 0x81000008L) + val calls = Calls() + AddedGameArt.fetchMissing(app, listOf(first, second), "key", sources(calls, sgdbFails = true)) + assertEquals(2, calls.steam.size) + assertEquals(1, calls.lookups) + assertFalse(File(AddedGameArt.cacheDir(app, first), AddedGameArt.EXTRAS_TRIED).exists()) + assertFalse(File(AddedGameArt.cacheDir(app, second), AddedGameArt.EXTRAS_TRIED).exists()) + } + + @Test fun nothingIsFetchedWhenArtIsOff() { + SessionPrefs.setAddedGamesArt(app, false) + val calls = Calls() + assertFalse(AddedGameArt.fetchMissing(app, listOf(game("Off", 0x81000009L)), "key", sources(calls))) + assertTrue(calls.steam.isEmpty()) + } +}