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.
This commit is contained in:
ovurrsl committed 2026-10-19 15:12:47 +03:00
1 parent 8ceff5641a
commit be384ca2a2
3 files changed
+260 -36

No files matched your search

@@ -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<AddedGames.Game>): Boolean {
internal class Sources(
val storeLogo: (AddedGames.Game) -> Boolean,
val steamSearch: (String) -> Int?,
val steam: (String, File, Set<Slot>) -> Boolean,
val sgdbGame: (String, Int?, String) -> Int?,
val sgdb: (String, Int, File, Set<Slot>) -> 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<Slot> {
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<AddedGames.Game>): Boolean =
fetchMissing(context, games, SteamGridDb.key(context), network)
internal fun fetchMissing(context: Context, games: List<AddedGames.Game>, 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<Slot>): 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
@@ -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> = 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")
@@ -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<Set<Slot>>()
val sgdb = ArrayList<Set<Slot>>()
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<Slot> = emptySet(), sgdbHas: Set<Slot> = 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())
}
}