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
implementation(libs.datastore.prefs)
// Image loading
implementation(libs.coil.compose)
// Testing
testImplementation(libs.junit)
androidTestImplementation(libs.androidx.junit)

View File

@@ -11,6 +11,7 @@
android:maxSdkVersion="32" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"
tools:ignore="ScopedStorage" />
<uses-permission android:name="android.permission.INTERNET" />
<application
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.os.Environment
import android.provider.DocumentsContract
import com.lazy.emulate.data.CoverArtManager
import com.lazy.emulate.data.model.Game
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.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import java.io.File
import java.util.UUID
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())
val games: StateFlow<List<Game>> = _games.asStateFlow()
@@ -51,17 +59,23 @@ class GameRepository(private val context: Context) {
files.forEach { file ->
if (file.absolutePath in existingPaths) return@forEach
val ext = file.extension.lowercase()
// Skip .bin files that have a matching .cue
if (ext == "bin" && file.nameWithoutExtension.lowercase() in cueBasenames) {
return@forEach
// Skip .bin files that belong to a .cue (exact match or multi-track)
if (ext == "bin") {
val binName = file.nameWithoutExtension.lowercase()
if (cueBasenames.any { binName == it || binName.startsWith("$it ") }) {
return@forEach
}
}
if (ext in consoleType.fileExtensions || ext == "zip" || ext == "7z") {
val title = file.nameWithoutExtension
val cachedArt = coverArtManager.getCachedPath(title, consoleType)
scanned.add(
Game(
id = UUID.randomUUID().toString(),
title = file.nameWithoutExtension,
title = title,
romPath = file.absolutePath,
consoleType = consoleType
consoleType = consoleType,
coverArtPath = cachedArt
)
)
}
@@ -70,6 +84,23 @@ class GameRepository(private val context: Context) {
if (scanned.isNotEmpty()) {
_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 fileExtensions: List<String>,
val isImplemented: Boolean = false,
val fallbackAspectRatio: Float = 4f / 3f
val fallbackAspectRatio: Float = 4f / 3f,
val libretroThumbnailRepo: String = ""
) {
PS1(
displayName = "PlayStation",
manufacturer = "Sony",
fileExtensions = listOf("bin", "cue", "iso", "img", "pbp", "chd"),
isImplemented = true,
fallbackAspectRatio = 4f / 3f
fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sony_-_PlayStation"
),
NES(
displayName = "Nintendo Entertainment System",
manufacturer = "Nintendo",
fileExtensions = listOf("nes", "unf", "unif"),
fallbackAspectRatio = 256f / 240f
fallbackAspectRatio = 256f / 240f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_Entertainment_System"
),
SNES(
displayName = "Super Nintendo",
manufacturer = "Nintendo",
fileExtensions = listOf("sfc", "smc", "fig", "swc"),
fallbackAspectRatio = 4f / 3f
fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Nintendo_-_Super_Nintendo_Entertainment_System"
),
N64(
displayName = "Nintendo 64",
manufacturer = "Nintendo",
fileExtensions = listOf("n64", "z64", "v64", "ndd"),
fallbackAspectRatio = 4f / 3f
fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Nintendo_-_Nintendo_64"
),
GENESIS(
displayName = "Sega Genesis",
manufacturer = "Sega",
fileExtensions = listOf("gen", "md", "smd", "bin"),
fallbackAspectRatio = 4f / 3f
fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sega_-_Mega_Drive_-_Genesis"
),
PS2(
displayName = "PlayStation 2",
manufacturer = "Sony",
fileExtensions = listOf("iso", "bin", "chd", "cso"),
fallbackAspectRatio = 4f / 3f
fallbackAspectRatio = 4f / 3f,
libretroThumbnailRepo = "Sony_-_PlayStation_2"
);
companion object {

View File

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