Add automatic cover art fetching from LibRetro thumbnails

Downloads box art from libretro-thumbnails GitHub repos for all
supported consoles (PS1, NES, SNES, N64, Genesis, PS2). Art is
fetched in the background on first scan and cached locally. Also
fixes multi-track .bin files showing as separate game entries.
This commit is contained in:
2026-04-09 21:09:26 -04:00
parent 0b4e408d18
commit 547d1501c0
7 changed files with 183 additions and 19 deletions

View File

@@ -80,6 +80,9 @@ dependencies {
// DataStore // DataStore
implementation(libs.datastore.prefs) implementation(libs.datastore.prefs)
// Image loading
implementation(libs.coil.compose)
// Testing // Testing
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.junit)

View File

@@ -11,6 +11,7 @@
android:maxSdkVersion="32" /> android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" <uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" /> tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<application <application
android:allowBackup="true" android:allowBackup="true"

View File

@@ -0,0 +1,105 @@
package com.lazy.emulate.data
import android.content.Context
import android.util.Log
import com.lazy.emulate.emulation.ConsoleType
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.net.URLEncoder
class CoverArtManager(private val context: Context) {
private val cacheDir = File(context.filesDir, "cover_art")
init {
cacheDir.mkdirs()
}
fun getCachedPath(gameTitle: String, consoleType: ConsoleType): String? {
val file = cacheFile(gameTitle, consoleType)
return if (file.exists()) file.absolutePath else null
}
suspend fun fetchCoverArt(gameTitle: String, consoleType: ConsoleType): String? {
val cached = getCachedPath(gameTitle, consoleType)
if (cached != null) return cached
if (consoleType.libretroThumbnailRepo.isEmpty()) return null
val dest = cacheFile(gameTitle, consoleType)
// Try the title as-is first, then cleaned up
val namesToTry = buildList {
add(gameTitle)
val cleaned = cleanTitle(gameTitle)
if (cleaned != gameTitle) add(cleaned)
}
for (name in namesToTry) {
val encoded = URLEncoder.encode(name, "UTF-8")
.replace("+", "%20")
val url = "$BASE_URL/${consoleType.libretroThumbnailRepo}/master/Named_Boxarts/$encoded.png"
if (downloadFile(url, dest)) {
return dest.absolutePath
}
}
return null
}
private fun cacheFile(gameTitle: String, consoleType: ConsoleType): File {
val safeTitle = gameTitle.replace(Regex("[^a-zA-Z0-9._\\- ]"), "_")
val dir = File(cacheDir, consoleType.name)
dir.mkdirs()
return File(dir, "$safeTitle.png")
}
private suspend fun downloadFile(urlStr: String, dest: File): Boolean = withContext(Dispatchers.IO) {
try {
val connection = URL(urlStr).openConnection() as HttpURLConnection
connection.connectTimeout = 10_000
connection.readTimeout = 10_000
connection.instanceFollowRedirects = true
if (connection.responseCode != 200) {
connection.disconnect()
return@withContext false
}
connection.inputStream.use { input ->
dest.outputStream().use { output ->
input.copyTo(output)
}
}
connection.disconnect()
true
} catch (e: Exception) {
Log.d(TAG, "Failed to download cover art: ${e.message}")
dest.delete()
false
}
}
companion object {
private const val TAG = "CoverArtManager"
private const val BASE_URL = "https://raw.githubusercontent.com/libretro-thumbnails"
fun cleanTitle(title: String): String {
return title
// Remove region tags: (USA), (Europe), (Japan), etc.
.replace(Regex("\\s*\\([^)]*\\)"), "")
// Remove flags: [!], [b], etc.
.replace(Regex("\\s*\\[[^]]*]"), "")
// Remove leading "The " for matching (LibRetro uses "Game, The" format)
.trim()
.let { cleaned ->
if (cleaned.startsWith("The ", ignoreCase = true)) {
cleaned.removePrefix("The ").removePrefix("the ") + ", The"
} else cleaned
}
}
}
}

View File

@@ -4,16 +4,24 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import android.provider.DocumentsContract import android.provider.DocumentsContract
import com.lazy.emulate.data.CoverArtManager
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import com.lazy.emulate.emulation.ConsoleType import com.lazy.emulate.emulation.ConsoleType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
class GameRepository(private val context: Context) { class GameRepository(private val context: Context) {
private val coverArtManager = CoverArtManager(context)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val _games = MutableStateFlow<List<Game>>(emptyList()) private val _games = MutableStateFlow<List<Game>>(emptyList())
val games: StateFlow<List<Game>> = _games.asStateFlow() val games: StateFlow<List<Game>> = _games.asStateFlow()
@@ -51,17 +59,23 @@ class GameRepository(private val context: Context) {
files.forEach { file -> files.forEach { file ->
if (file.absolutePath in existingPaths) return@forEach if (file.absolutePath in existingPaths) return@forEach
val ext = file.extension.lowercase() val ext = file.extension.lowercase()
// Skip .bin files that have a matching .cue // Skip .bin files that belong to a .cue (exact match or multi-track)
if (ext == "bin" && file.nameWithoutExtension.lowercase() in cueBasenames) { if (ext == "bin") {
return@forEach val binName = file.nameWithoutExtension.lowercase()
if (cueBasenames.any { binName == it || binName.startsWith("$it ") }) {
return@forEach
}
} }
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") { if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
val title = file.nameWithoutExtension
val cachedArt = coverArtManager.getCachedPath(title, consoleType)
scanned.add( scanned.add(
Game( Game(
id = UUID.randomUUID().toString(), id = UUID.randomUUID().toString(),
title = file.nameWithoutExtension, title = title,
romPath = file.absolutePath, romPath = file.absolutePath,
consoleType = consoleType consoleType = consoleType,
coverArtPath = cachedArt
) )
) )
} }
@@ -70,6 +84,23 @@ class GameRepository(private val context: Context) {
if (scanned.isNotEmpty()) { if (scanned.isNotEmpty()) {
_games.value = _games.value + scanned _games.value = _games.value + scanned
fetchMissingCoverArt()
}
}
private fun fetchMissingCoverArt() {
val gamesNeedingArt = _games.value.filter { it.coverArtPath == null }
if (gamesNeedingArt.isEmpty()) return
scope.launch {
for (game in gamesNeedingArt) {
val path = coverArtManager.fetchCoverArt(game.title, game.consoleType)
if (path != null) {
_games.value = _games.value.map {
if (it.id == game.id) it.copy(coverArtPath = path) else it
}
}
}
} }
} }

View File

@@ -5,44 +5,51 @@ enum class ConsoleType(
val manufacturer: String, val manufacturer: String,
val fileExtensions: List<String>, val fileExtensions: List<String>,
val isImplemented: Boolean = false, val isImplemented: Boolean = false,
val fallbackAspectRatio: Float = 4f / 3f val fallbackAspectRatio: Float = 4f / 3f,
val libretroThumbnailRepo: String = ""
) { ) {
PS1( PS1(
displayName = "PlayStation", displayName = "PlayStation",
manufacturer = "Sony", manufacturer = "Sony",
fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"), fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"),
isImplemented = true, isImplemented = true,
fallbackAspectRatio = 4f / 3f fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sony_-_PlayStation"
), ),
NES( NES(
displayName = "Nintendo Entertainment System", displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif"), fileExtensions = listOf("nes", "unf", "unif"),
fallbackAspectRatio = 256f / 240f fallbackAspectRatio = 256f / 240f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
), ),
SNES( SNES(
displayName = "Super Nintendo", displayName = "Super Nintendo",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("sfc", "smc", "fig", "swc"), fileExtensions = listOf("sfc", "smc", "fig", "swc"),
fallbackAspectRatio = 4f / 3f fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Nintendo_-_Super_Nintendo_Entertainment_System"
), ),
N64( N64(
displayName = "Nintendo 64", displayName = "Nintendo 64",
manufacturer = "Nintendo", manufacturer = "Nintendo",
fileExtensions = listOf("n64", "z64", "v64", "ndd"), fileExtensions = listOf("n64", "z64", "v64", "ndd"),
fallbackAspectRatio = 4f / 3f fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_64"
), ),
GENESIS( GENESIS(
displayName = "Sega Genesis", displayName = "Sega Genesis",
manufacturer = "Sega", manufacturer = "Sega",
fileExtensions = listOf("gen", "md", "smd", "bin"), fileExtensions = listOf("gen", "md", "smd", "bin"),
fallbackAspectRatio = 4f / 3f fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sega_-_Mega_Drive_-_Genesis"
), ),
PS2( PS2(
displayName = "PlayStation 2", displayName = "PlayStation 2",
manufacturer = "Sony", manufacturer = "Sony",
fileExtensions = listOf("iso", "bin", "chd", "cso"), fileExtensions = listOf("iso", "bin", "chd", "cso"),
fallbackAspectRatio = 4f / 3f fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sony_-_PlayStation_2"
); );
companion object { companion object {

View File

@@ -5,6 +5,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
@@ -22,9 +23,12 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.lazy.emulate.data.model.Game import com.lazy.emulate.data.model.Game
import java.io.File
@Composable @Composable
fun GameCard( fun GameCard(
@@ -41,7 +45,7 @@ fun GameCard(
elevation = CardDefaults.cardElevation(defaultElevation = 4.dp) elevation = CardDefaults.cardElevation(defaultElevation = 4.dp)
) { ) {
Column { Column {
// Cover art placeholder // Cover art
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -50,12 +54,21 @@ fun GameCard(
.background(MaterialTheme.colorScheme.surfaceVariant), .background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( if (game.coverArtPath != null) {
imageVector = Icons.Default.SportsEsports, AsyncImage(
contentDescription = null, model = File(game.coverArtPath),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f), contentDescription = "${game.title} cover art",
modifier = Modifier.padding(32.dp) contentScale = ContentScale.Crop,
) modifier = Modifier.fillMaxSize()
)
} else {
Icon(
imageVector = Icons.Default.SportsEsports,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f),
modifier = Modifier.padding(32.dp)
)
}
// Console badge // Console badge
Text( Text(

View File

@@ -14,6 +14,7 @@ lifecycle = "2.9.0"
coroutines = "1.10.2" coroutines = "1.10.2"
windowSizeClass = "1.3.2" windowSizeClass = "1.3.2"
datastorePrefs = "1.1.4" datastorePrefs = "1.1.4"
coil = "2.7.0"
[libraries] [libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
@@ -47,6 +48,9 @@ coroutines-android = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutin
# DataStore # DataStore
datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePrefs" } datastore-prefs = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastorePrefs" }
# Coil (image loading)
coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" }
[plugins] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }