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 @Singleton @Binds fun appFiles(impl: AppFilesImpl): AppFiles
@Binds fun coverCache(cache: CoverCacheImpl): CoverCache @Binds fun coverCache(cache: CoverCacheImpl): CoverCache
@Binds fun coverIdentifier(identifierImpl: CoverIdentifierImpl): CoverIdentifier
} }
@Module @Module

View file

@ -25,73 +25,60 @@ import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream 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 kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.oxycblt.auxio.image.Cover 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): InputStream? suspend fun write(cover: Cover.Single, data: ByteArray): InputStream?
} }
class CoverCacheImpl class CoverCacheImpl
@Inject @Inject
constructor(private val storedCoversDao: StoredCoversDao, private val appFiles: AppFiles) : constructor(
CoverCache { private val coverIdentifier: CoverIdentifier,
private val storedCoversDao: StoredCoversDao,
private val appFiles: AppFiles
) : CoverCache {
override suspend fun read(cover: Cover.Single): InputStream? { override suspend fun read(cover: Cover.Single): InputStream? {
val perceptualHash = val perceptualHash =
storedCoversDao.getCoverFile(cover.uid, cover.lastModified) ?: return null storedCoversDao.getStoredCover(cover.uid, cover.lastModified) ?: return null
return appFiles.read(fileName(perceptualHash)) return appFiles.read(fileName(perceptualHash))
} }
override suspend fun write(cover: Cover.Single, inputStream: InputStream): InputStream? { override suspend fun write(cover: Cover.Single, data: ByteArray): InputStream? {
val id = val id = coverIdentifier.identify(data)
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()
}
val file = fileName(id) val file = fileName(id)
if (!appFiles.exists(file)) { if (!appFiles.exists(file)) {
val transcoded = transcodeImage(inputStream, FORMAT_WEBP) val transcoded = transcodeImage(data, FORMAT_WEBP)
val writeSuccess = appFiles.write(fileName(id), transcoded) val writeSuccess = appFiles.write(fileName(id), transcoded)
if (!writeSuccess) { if (!writeSuccess) {
return null return null
} }
} }
storedCoversDao.setCoverFile( storedCoversDao.setStoredCover(
StoredCover(uid = cover.uid, lastModified = cover.lastModified, perceptualHash = id)) StoredCover(uid = cover.uid, lastModified = cover.lastModified, coverId = id))
return appFiles.read(file) return appFiles.read(file)
} }
private fun fileName(id: String) = "cover_$id" private fun fileName(id: String) = "cover_$id"
private fun transcodeImage( private fun transcodeImage(data: ByteArray, targetFormat: Bitmap.CompressFormat): InputStream {
inputStream: InputStream,
targetFormat: Bitmap.CompressFormat
): InputStream {
val options = val options =
BitmapFactory.Options().apply { BitmapFactory.Options().apply {
inJustDecodeBounds = true inJustDecodeBounds = true
BitmapFactory.decodeStream(inputStream, null, this) BitmapFactory.decodeByteArray(data, 0, data.size, this)
} }
options.inSampleSize = calculateInSampleSize(options, 750, 750) options.inSampleSize = calculateInSampleSize(options, 750, 750)
inputStream.reset()
val bitmap = val bitmap =
BitmapFactory.decodeStream( BitmapFactory.decodeByteArray(
inputStream, null, options.apply { inJustDecodeBounds = false }) data, 0, data.size, options.apply { inJustDecodeBounds = false })
return ByteArrayOutputStream().use { outputStream -> return ByteArrayOutputStream().use { outputStream ->
bitmap?.compress(targetFormat, 80, outputStream) bitmap?.compress(targetFormat, 80, outputStream)
@ -119,7 +106,6 @@ constructor(private val storedCoversDao: StoredCoversDao, private val appFiles:
} }
private companion object { private companion object {
const val COVER_KEY_SAMPLE = 32
val FORMAT_WEBP = val FORMAT_WEBP =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
Bitmap.CompressFormat.WEBP_LOSSY 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 @Dao
interface StoredCoversDao { interface StoredCoversDao {
@Query( @Query("SELECT coverId FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
"SELECT perceptualHash FROM StoredCover WHERE uid = :uid AND lastModified = :lastModified")
@TypeConverters(Music.UID.TypeConverters::class) @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 @Entity
@TypeConverters(Music.UID.TypeConverters::class) @TypeConverters(Music.UID.TypeConverters::class)
data class StoredCover( data class StoredCover(@PrimaryKey val uid: Music.UID, val lastModified: Long, val coverId: String)
@PrimaryKey val uid: Music.UID,
val lastModified: Long,
val perceptualHash: String
)

View file

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

View file

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

View file

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