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:
parent
e061f7cb26
commit
6b8b147721
7 changed files with 76 additions and 61 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
38
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverIdentifier.kt
vendored
Normal file
38
app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverIdentifier.kt
vendored
Normal 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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue