diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt index c34b69d5c..eddd90b9d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CacheModule.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt index 88bd4e008..3199b132e 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverCache.kt @@ -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 diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverIdentifier.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverIdentifier.kt new file mode 100644 index 000000000..69ae53d60 --- /dev/null +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/CoverIdentifier.kt @@ -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 . + */ + +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() + } +} diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt index f168cdf95..b9fbae075 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/cache/StoredCoversDatabase.kt @@ -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) diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/AOSPCoverSource.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/AOSPCoverSource.kt index 688476128..c2920008d 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/AOSPCoverSource.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/AOSPCoverSource.kt @@ -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 + } } } diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/CoverExtractor.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/CoverExtractor.kt index 885f1618e..d491e1780 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/CoverExtractor.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/CoverExtractor.kt @@ -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) 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) { diff --git a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/ExoPlayerCoverSource.kt b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/ExoPlayerCoverSource.kt index 576d26a4a..79875f836 100644 --- a/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/ExoPlayerCoverSource.kt +++ b/app/src/main/java/org/oxycblt/auxio/image/stack/extractor/ExoPlayerCoverSource.kt @@ -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 } }