image: further improve cover caching

- Don't rewrite files if they already exist
- Use webp compression
- Downsize cover images to save memory
This commit is contained in:
Alexander Capehart 2024-11-29 13:17:06 -07:00
parent c74c62d9b3
commit e061f7cb26
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
3 changed files with 84 additions and 27 deletions

View file

@ -33,9 +33,5 @@ class CoverRetrieverImpl
constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) : constructor(private val coverCache: CoverCache, private val coverExtractor: CoverExtractor) :
CoverRetriever { CoverRetriever {
override suspend fun retrieve(cover: Cover.Single) = override suspend fun retrieve(cover: Cover.Single) =
coverCache.read(cover) coverCache.read(cover) ?: coverExtractor.extract(cover)?.let { coverCache.write(cover, it) }
?: coverExtractor.extract(cover)?.also {
coverCache.write(cover, it)
it.reset()
}
} }

View file

@ -34,6 +34,8 @@ interface AppFiles {
suspend fun read(file: String): InputStream? suspend fun read(file: String): InputStream?
suspend fun write(file: String, inputStream: InputStream): Boolean suspend fun write(file: String, inputStream: InputStream): Boolean
suspend fun exists(file: String): Boolean
} }
class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) : class AppFilesImpl @Inject constructor(@ApplicationContext private val context: Context) :
@ -77,4 +79,7 @@ class AppFilesImpl @Inject constructor(@ApplicationContext private val context:
} }
} }
} }
override suspend fun exists(file: String): Boolean =
withContext(Dispatchers.IO) { File(context.filesDir, file).exists() }
} }

View file

@ -18,6 +18,11 @@
package org.oxycblt.auxio.image.stack.cache package org.oxycblt.auxio.image.stack.cache
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream import java.io.InputStream
import javax.inject.Inject import javax.inject.Inject
import kotlin.math.min import kotlin.math.min
@ -28,7 +33,7 @@ import org.oxycblt.auxio.image.Cover
interface CoverCache { interface CoverCache {
suspend fun read(cover: Cover.Single): InputStream? suspend fun read(cover: Cover.Single): InputStream?
suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream?
} }
class CoverCacheImpl class CoverCacheImpl
@ -43,7 +48,8 @@ constructor(private val storedCoversDao: StoredCoversDao, private val appFiles:
return appFiles.read(fileName(perceptualHash)) return appFiles.read(fileName(perceptualHash))
} }
override suspend fun write(cover: Cover.Single, inputStream: InputStream): Boolean = override suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream? {
val id =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val available = inputStream.available() val available = inputStream.available()
val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong()) val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
@ -51,24 +57,74 @@ constructor(private val storedCoversDao: StoredCoversDao, private val appFiles:
val bytes = ByteArray(COVER_KEY_SAMPLE) val bytes = ByteArray(COVER_KEY_SAMPLE)
inputStream.read(bytes) inputStream.read(bytes)
inputStream.reset() inputStream.reset()
@OptIn(ExperimentalStdlibApi::class) bytes.toHexString()
}
val file = fileName(id)
if (!appFiles.exists(file)) {
val transcoded = transcodeImage(inputStream, FORMAT_WEBP)
val writeSuccess = appFiles.write(fileName(id), transcoded)
if (!writeSuccess) {
return null
}
}
@OptIn(ExperimentalStdlibApi::class) val perceptualHash = bytes.toHexString()
val writeSuccess = appFiles.write(fileName(perceptualHash), inputStream)
if (writeSuccess) {
storedCoversDao.setCoverFile( storedCoversDao.setCoverFile(
StoredCover( StoredCover(uid = cover.uid, lastModified = cover.lastModified, perceptualHash = id))
uid = cover.uid,
lastModified = cover.lastModified, return appFiles.read(file)
perceptualHash = perceptualHash))
} }
writeSuccess private fun fileName(id: String) = "cover_$id"
private fun transcodeImage(
inputStream: InputStream,
targetFormat: Bitmap.CompressFormat
): InputStream {
val options =
BitmapFactory.Options().apply {
inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, this)
} }
private fun fileName(perceptualHash: String) = "cover_$perceptualHash" options.inSampleSize = calculateInSampleSize(options, 750, 750)
inputStream.reset()
val bitmap =
BitmapFactory.decodeStream(
inputStream, null, options.apply { inJustDecodeBounds = false })
return ByteArrayOutputStream().use { outputStream ->
bitmap?.compress(targetFormat, 80, outputStream)
ByteArrayInputStream(outputStream.toByteArray())
}
}
private fun calculateInSampleSize(
options: BitmapFactory.Options,
reqWidth: Int,
reqHeight: Int
): Int {
var inSampleSize = 1
val (height, width) = options.outHeight to options.outWidth
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while ((halfHeight / inSampleSize) >= reqHeight &&
(halfWidth / inSampleSize) >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
private companion object { private companion object {
const val COVER_KEY_SAMPLE = 32 const val COVER_KEY_SAMPLE = 32
val FORMAT_WEBP =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY
} else {
Bitmap.CompressFormat.WEBP
}
} }
} }