image: improve cover cache design

- Don't send around InputStreams when really we are extracting ByteArray
- Hash with MD5, which should be a good enough tm hash even if easily
collideable
- Split off cover identification into another object
This commit is contained in:
Alexander Capehart 2024-11-29 13:28:05 -07:00
parent e061f7cb26
commit 6b8b147721
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
7 changed files with 76 additions and 61 deletions

View file

@ -34,6 +34,8 @@ interface StackModule {
@Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache
@Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier
}
@Module

View file

@ -25,73 +25,60 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import javax.inject.Inject
import kotlin.math.min
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.Cover
interface CoverCache {
suspend fun read(cover: Cover.Single): InputStream?
suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream?
suspend fun write(cover: Cover.Single, data: ByteArray): InputStream?
}
class CoverCacheImpl
@Inject
constructor(private val storedCoversDao: StoredCoversDao, private val appFiles: AppFiles) :
CoverCache {
constructor(
private val coverIdentifier: CoverIdentifier,
private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles
) : CoverCache {
override suspend fun read(cover: Cover.Single): InputStream? {
val perceptualHash =
storedCoversDao.getCoverFile(cover.uid, cover.lastModified) ?: return null
storedCoversDao.getStoredCover(cover.uid, cover.lastModified) ?: return null
return appFiles.read(fileName(perceptualHash))
}
override suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream? {
val id =
withContext(Dispatchers.IO) {
val available = inputStream.available()
val skip = min(available / 2L, available - COVER_KEY_SAMPLE.toLong())
inputStream.skip(skip)
val bytes = ByteArray(COVER_KEY_SAMPLE)
inputStream.read(bytes)
inputStream.reset()
@OptIn(ExperimentalStdlibApi::class) bytes.toHexString()
}
override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? {
val id = coverIdentifier.identify(data)
val file = fileName(id)
if (!appFiles.exists(file)) {
val transcoded = transcodeImage(inputStream, FORMAT_WEBP)
val transcoded = transcodeImage(data, FORMAT_WEBP)
val writeSuccess = appFiles.write(fileName(id), transcoded)
if (!writeSuccess) {
return null
}
}
storedCoversDao.setCoverFile(
StoredCover(uid = cover.uid, lastModified = cover.lastModified, perceptualHash = id))
storedCoversDao.setStoredCover(
StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id))
return appFiles.read(file)
}
private fun fileName(id: String) = "cover_$id"
private fun transcodeImage(
inputStream: InputStream,
targetFormat: Bitmap.CompressFormat
): InputStream {
private fun transcodeImage(data: ByteArray, targetFormat: Bitmap.CompressFormat): InputStream {
val options =
BitmapFactory.Options().apply {
inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, this)
BitmapFactory.decodeByteArray(data, 0, data.size, this)
}
options.inSampleSize = calculateInSampleSize(options, 750, 750)
inputStream.reset()
val bitmap =
BitmapFactory.decodeStream(
inputStream, null, options.apply { inJustDecodeBounds = false })
BitmapFactory.decodeByteArray(
data, 0, data.size, options.apply { inJustDecodeBounds = false })
return ByteArrayOutputStream().use { outputStream ->
bitmap?.compress(targetFormat, 80, outputStream)
@ -119,7 +106,6 @@ constructor(private val storedCoversDao: StoredCoversDao, private val appFiles:
}
private companion object {
const val COVER_KEY_SAMPLE = 32
val FORMAT_WEBP =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 Auxio Project
* CoverIdentifier.kt is part of Auxio.
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package org.oxycblt.auxio.image.stack.cache
import java.security.MessageDigest
import javax.inject.Inject
interface CoverIdentifier {
suspend fun identify(data: ByteArray): String
}
class CoverIdentifierImpl @Inject constructor() : CoverIdentifier {
@OptIn(ExperimentalStdlibApi::class)
override suspend fun identify(data: ByteArray): String {
val digest =
MessageDigest.getInstance("MD5").run {
update(data)
digest()
}
return digest.toHexString()
}
}

View file

@ -36,18 +36,14 @@ abstract class StoredCoversDatabase : RoomDatabase() {
@Dao
interface StoredCoversDao {
@Query(
"SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@TypeConverters(Music.UID.TypeConverters::class)
fun getCoverFile(uid: Music.UID, lastModified: Long): String?
suspend fun getStoredCover(uid: Music.UID, lastModified: Long): String?
@Insert(onConflict = OnConflictStrategy.REPLACE) fun setCoverFile(storedCover: StoredCover)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun setStoredCover(storedCover: StoredCover)
}
@Entity
@TypeConverters(Music.UID.TypeConverters::class)
data class StoredCover(
@PrimaryKey val uid: Music.UID,
val lastModified: Long,
val perceptualHash: String
)
data class StoredCover(@PrimaryKey val uid: Music.UID, val lastModified: Long, val coverId: String)

View file

@ -22,20 +22,17 @@ import android.content.Context
import android.media.MediaMetadataRetriever
import android.net.Uri
import dagger.hilt.android.qualifiers.ApplicationContext
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
class AOSPCoverSource @Inject constructor(@ApplicationContext private val context: Context) :
CoverSource {
override suspend fun extract(fileUri: Uri): InputStream? {
override suspend fun extract(fileUri: Uri): ByteArray? {
val mediaMetadataRetriever = MediaMetadataRetriever()
val cover =
withContext(Dispatchers.IO) {
mediaMetadataRetriever.setDataSource(context, fileUri)
mediaMetadataRetriever.embeddedPicture
} ?: return null
return cover.inputStream()
return withContext(Dispatchers.IO) {
mediaMetadataRetriever.setDataSource(context, fileUri)
mediaMetadataRetriever.embeddedPicture
}
}
}

View file

@ -19,23 +19,22 @@
package org.oxycblt.auxio.image.stack.extractor
import android.net.Uri
import java.io.InputStream
import javax.inject.Inject
import org.oxycblt.auxio.image.Cover
interface CoverExtractor {
suspend fun extract(cover: Cover.Single): InputStream?
suspend fun extract(cover: Cover.Single): ByteArray?
}
data class CoverSources(val sources: List<CoverSource>)
interface CoverSource {
suspend fun extract(fileUri: Uri): InputStream?
suspend fun extract(fileUri: Uri): ByteArray?
}
class CoverExtractorImpl @Inject constructor(private val coverSources: CoverSources) :
CoverExtractor {
override suspend fun extract(cover: Cover.Single): InputStream? {
override suspend fun extract(cover: Cover.Single): ByteArray? {
for (coverSource in coverSources.sources) {
val stream = coverSource.extract(cover.uri)
if (stream != null) {

View file

@ -26,15 +26,13 @@ import androidx.media3.exoplayer.MetadataRetriever
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.extractor.metadata.flac.PictureFrame
import androidx.media3.extractor.metadata.id3.ApicFrame
import java.io.ByteArrayInputStream
import java.io.InputStream
import javax.inject.Inject
import kotlinx.coroutines.guava.asDeferred
class ExoPlayerCoverSource
@Inject
constructor(private val mediaSourceFactory: MediaSource.Factory) : CoverSource {
override suspend fun extract(fileUri: Uri): InputStream? {
override suspend fun extract(fileUri: Uri): ByteArray? {
val tracks =
MetadataRetriever.retrieveMetadata(mediaSourceFactory, MediaItem.fromUri(fileUri))
.asDeferred()
@ -52,8 +50,8 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : CoverSource {
return findCoverDataInMetadata(metadata)
}
private fun findCoverDataInMetadata(metadata: Metadata): InputStream? {
var stream: ByteArrayInputStream? = null
private fun findCoverDataInMetadata(metadata: Metadata): ByteArray? {
var fallbackPic: ByteArray? = null
for (i in 0 until metadata.length()) {
// We can only extract pictures from two tags with this method, ID3v2's APIC or
@ -74,13 +72,12 @@ constructor(private val mediaSourceFactory: MediaSource.Factory) : CoverSource {
}
if (type == MediaMetadata.PICTURE_TYPE_FRONT_COVER) {
stream = ByteArrayInputStream(pic)
break
} else if (stream == null) {
stream = ByteArrayInputStream(pic)
return pic
} else if (fallbackPic == null) {
fallbackPic = pic
}
}
return stream
return fallbackPic
}
}