Pre-fetch cover art to <DataRoot>/covers with a Download cover art action

This commit is contained in:
jpolo1224
2026-08-03 13:25:23 -04:00
parent 5eb3fb119a
commit cf8f441ce1
4 changed files with 126 additions and 0 deletions
@@ -289,6 +289,12 @@ data class GameInfo(
val coverUrl: String? get() {
localCoverPath()?.let { return Uri.fromFile(File(it)).toString() }
val s = coverSerial ?: return null
// A cover pre-fetched into <DataRoot>/covers beats the network: it renders instantly,
// works offline, and survives a cache clear. Only 2D art is stored this way, so the 3D
// style still goes to the network for its own URL.
if (!CoverArtStyle.use3d.value) {
com.armsx2.core.Ps1Covers.downloadedCover(s)?.let { return Uri.fromFile(it).toString() }
}
// 3D cases live under covers/3d/*.png; flat 2D scans under
// covers/default/*.jpg. Coil decodes by content, so the extension
// mismatch on the cached file is fine.
@@ -119,4 +119,81 @@ object Ps1Covers {
}
return null
}
/* ---------------------------------------------------------------------------------------
Persistent, pre-fetched covers.
Coil already fetches a cover the moment a tile scrolls into view, and caches it — but that
is LAZY: nothing exists until the tile has been on screen, art never appears offline, and a
cache clear silently loses the lot. Downloading to `<DataRoot>/covers/<SERIAL>.jpg` makes
covers real files the user owns: visible in the data folder, survive a cache wipe, work
with no network, and can be hand-replaced.
`covers` is already in Ps1Library.BLOCKED_DIRS, so these never get scanned back in as games.
--------------------------------------------------------------------------------------- */
/** `<DataRoot>/covers`, created on demand. Null when no data root is configured yet. */
fun coversDir(): File? {
val root = com.armsx2.runtime.MainActivityRuntime.systemDirPosix() ?: return null
return File(root, "covers").apply { runCatching { mkdirs() } }
}
/** The downloaded cover for [serial], or null if it has not been fetched. */
fun downloadedCover(serial: String): File? =
coversDir()?.let { File(it, "${serial.uppercase(Locale.US)}.jpg") }?.takeIf { it.isFile && it.length() > 0 }
/**
* Fetch one cover into [coversDir]. Returns true if the file is present afterwards.
* Already-downloaded covers are a no-op, so this is safe to call repeatedly.
*
* **Blocking.** Call from an IO dispatcher.
*/
fun downloadCover(serial: String): Boolean {
if (serial.isBlank()) return false
downloadedCover(serial)?.let { return true }
val dir = coversDir() ?: return false
val target = File(dir, "${serial.uppercase(Locale.US)}.jpg")
// Write to a temp name first: a half-written file that already has the final name would
// be treated as a valid cover forever after.
val temp = File(dir, ".${target.name}.part")
return runCatching {
val connection = (java.net.URL(coverUrl(serial)).openConnection() as java.net.HttpURLConnection).apply {
connectTimeout = 15_000
readTimeout = 15_000
instanceFollowRedirects = true
}
try {
if (connection.responseCode != 200) return false
connection.inputStream.use { input -> temp.outputStream().use(input::copyTo) }
} finally {
connection.disconnect()
}
if (temp.length() <= 0) {
temp.delete()
false
} else {
temp.renameTo(target) || run { temp.delete(); false }
}
}.getOrElse {
temp.delete()
false
}
}
/**
* Download every cover that is missing, one at a time. [serials] is deduplicated and blanks
* are dropped. [onProgress] fires after each attempt with (done, total) so a UI can show
* where it is. Returns how many covers were newly fetched.
*
* **Blocking.** Call from an IO dispatcher.
*/
fun downloadMissing(serials: Collection<String>, onProgress: (Int, Int) -> Unit = { _, _ -> }): Int {
val wanted = serials.mapNotNull { it.takeIf(String::isNotBlank)?.uppercase(Locale.US) }.distinct()
var fetched = 0
wanted.forEachIndexed { index, serial ->
if (downloadedCover(serial) == null && downloadCover(serial)) fetched++
onProgress(index + 1, wanted.size)
}
return fetched
}
}
@@ -309,6 +309,11 @@ val EN: Map<String, String> = mapOf(
"app.backup.export.desc" to "Save states, memory cards, artwork, per-game settings, controller profiles, patches and all settings into one .zip. Games and BIOS are not included.",
"app.backup.import" to "Restore app data",
"app.backup.import.desc" to "Load a backup .zip. Files with the same name are replaced, and the app restarts.",
"app.covers" to "Download cover art",
"app.covers.desc" to "Fetch box art for every game in your library and keep it on disk, so covers show up straight away and work offline.",
"app.covers.done" to "Downloaded %d covers",
"app.covers.upToDate" to "All covers already downloaded",
"app.covers.none" to "No games found to fetch covers for",
"app.reset" to "Reset app",
"app.reset.desc" to "Restore every setting to its default. Your games and saves are kept.",
"app.reset.title" to "Reset the whole app?",
@@ -701,6 +701,44 @@ private fun BackupRestoreRows() {
BackupActionRow("💾", "app.backup.export", "app.backup.export.desc", status, busy, doExport)
BackupActionRow("📥", "app.backup.import", "app.backup.import.desc", "", busy, doImport)
/*
Fetch every missing cover up front, into <DataRoot>/covers.
Coil already pulls a cover when a tile scrolls into view, but that is lazy and lives in a
cache: art does not exist until you have looked at the game, never appears offline, and
vanishes on a cache clear. This walks the whole library once and writes real files, so the
grid is populated before it is scrolled and stays populated afterwards.
*/
var coverStatus by remember { mutableStateOf("") }
val doCovers = {
if (!busy) {
busy = true
coverStatus = ""
scope.launch {
val result = withContext(Dispatchers.IO) {
val games = runCatching { com.armsx2.core.Ps1Library.scan(context) }.getOrDefault(emptyList())
val serials = games.mapNotNull { g ->
runCatching { com.armsx2.core.Ps1Covers.serialForPath(g.path) }.getOrNull()
}
if (serials.isEmpty()) return@withContext -1
com.armsx2.core.Ps1Covers.downloadMissing(serials) { done, total ->
coverStatus = "$done / $total"
}
}
coverStatus = when {
// No serial anywhere usually means the library itself is empty — which on
// Android 11+ is the all-files-access case, not a cover problem.
result < 0 -> I18n.get("app.covers.none")
result == 0 -> I18n.get("app.covers.upToDate")
else -> I18n.get("app.covers.done").replace("%d", result.toString())
}
busy = false
com.armsx2.core.Ps1Library.rescan.intValue++
}
}
}
BackupActionRow("🖼️", "app.covers", "app.covers.desc", coverStatus, busy, doCovers)
// Factory reset. Sits with Backup/Restore because Export is the thing to do first — the
// prompt says so. Routed through GlobalConfirm rather than a local overlay: this row is
// inside a scrolling tab, so a scrim drawn here would clip to the row's bounds.