mirror of
https://github.com/Droid-Deck/DroidDeck.git
synced 2026-11-12 03:21:08 +00:00
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:
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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user