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
|
@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
|
||||||
|
|
|
@ -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
|
||||||
|
|
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
|
@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
|
|
||||||
)
|
|
||||||
|
|
|
@ -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()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue