diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt index 16de62c99..175dbde36 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheModule.kt @@ -30,20 +30,20 @@ import javax.inject.Singleton @Module @InstallIn(SingletonComponent::class) -interface CacheModule { - @Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository +interface TagCacheModule { + @Binds fun tagCache(cacheRepository: TagCacheImpl): TagCache } @Module @InstallIn(SingletonComponent::class) -class CacheRoomModule { +class TagDatabaseModule { @Singleton @Provides fun database(@ApplicationContext context: Context) = Room.databaseBuilder( - context.applicationContext, CacheDatabase::class.java, "music_cache.db") + context.applicationContext, TagDatabase::class.java, "music_cache.db") .fallbackToDestructiveMigration() .build() - @Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao() + @Provides fun tagsDao(database: TagDatabase) = database.cachedSongsDao() } diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt deleted file mode 100644 index 0621b6ee7..000000000 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheRepository.kt +++ /dev/null @@ -1,121 +0,0 @@ -/* - * Copyright (c) 2022 Auxio Project - * CacheRepository.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.music.cache - -import javax.inject.Inject -import org.oxycblt.auxio.music.device.RawSong -import timber.log.Timber as L - -/** - * A repository allowing access to cached metadata obtained in prior music loading operations. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface CacheRepository { - /** - * Read the current [Cache], if it exists. - * - * @return The stored [Cache], or null if it could not be obtained. - */ - suspend fun readCache(): Cache? - - /** - * Write the list of newly-loaded [RawSong]s to the cache, replacing the prior data. - * - * @param rawSongs The [rawSongs] to write to the cache. - */ - suspend fun writeCache(rawSongs: List) -} - -class CacheRepositoryImpl @Inject constructor(private val cachedSongsDao: CachedSongsDao) : - CacheRepository { - override suspend fun readCache(): Cache? = - try { - // Faster to load the whole database into memory than do a query on each - // populate call. - val songs = cachedSongsDao.readSongs() - L.d("Successfully read ${songs.size} songs from cache") - CacheImpl(songs) - } catch (e: Exception) { - L.e("Unable to load cache database.") - L.e(e.stackTraceToString()) - null - } - - override suspend fun writeCache(rawSongs: List) { - try { - // Still write out whatever data was extracted. - cachedSongsDao.nukeSongs() - L.d("Successfully deleted old cache") - cachedSongsDao.insertSongs(rawSongs.map(CachedSong::fromRaw)) - L.d("Successfully wrote ${rawSongs.size} songs to cache") - } catch (e: Exception) { - L.e("Unable to save cache database.") - L.e(e.stackTraceToString()) - } - } -} - -/** - * A cache of music metadata obtained in prior music loading operations. Obtain an instance with - * [CacheRepository]. - * - * @author Alexander Capehart (OxygenCobalt) - */ -interface Cache { - /** Whether this cache has encountered a [RawSong] that did not have a cache entry. */ - val invalidated: Boolean - - /** - * Populate a [RawSong] from a cache entry, if it exists. - * - * @param rawSong The [RawSong] to populate. - * @return true if a cache entry could be applied to [rawSong], false otherwise. - */ - fun populate(rawSong: RawSong): Boolean -} - -private class CacheImpl(cachedSongs: List) : Cache { - private val cacheMap = buildMap { - for (cachedSong in cachedSongs) { - put(cachedSong.mediaStoreId, cachedSong) - } - } - - override var invalidated = false - - override fun populate(rawSong: RawSong): Boolean { - // For a cached raw song to be used, it must exist within the cache and have matching - // addition and modification timestamps. Technically the addition timestamp doesn't - // exist, but to safeguard against possible OEM-specific timestamp incoherence, we - // check for it anyway. - val cachedSong = cacheMap[rawSong.mediaStoreId] - if (cachedSong != null && - cachedSong.dateAdded == rawSong.dateAdded && - cachedSong.dateModified == rawSong.dateModified) { - cachedSong.copyToRaw(rawSong) - return true - } - - // We could not populate this song. This means our cache is stale and should be - // re-written with newly-loaded music. - invalidated = true - return false - } -} diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/TagCache.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/TagCache.kt index ec222b1aa..2dad7b98f 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/TagCache.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/TagCache.kt @@ -1,2 +1,36 @@ package org.oxycblt.auxio.music.cache +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.transform +import org.oxycblt.auxio.music.device.RawSong +import org.oxycblt.auxio.music.fs.DeviceFile +import org.oxycblt.auxio.music.metadata.TagResult +import javax.inject.Inject + +interface TagCache { + fun read(files: Flow): Flow + suspend fun write(rawSongs: Flow) +} + +class TagCacheImpl @Inject constructor( + private val tagDao: TagDao +) : TagCache { + override fun read(files: Flow) = + files.transform { file -> + val tags = tagDao.selectTags(file.uri.toString(), file.lastModified) + if (tags != null) { + val rawSong = RawSong(file = file) + tags.copyToRaw(rawSong) + TagResult.Hit(rawSong) + } else { + TagResult.Miss(file) + } + } + + override suspend fun write(rawSongs: Flow) { + rawSongs.collect { rawSong -> + tagDao.updateTags(Tags.fromRaw(rawSong)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt b/app/src/main/java/org/oxycblt/auxio/music/cache/TagDatabase.kt similarity index 82% rename from app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt rename to app/src/main/java/org/oxycblt/auxio/music/cache/TagDatabase.kt index 5ea7c8ebf..1d62adb3e 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/cache/CacheDatabase.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/cache/TagDatabase.kt @@ -22,6 +22,7 @@ import androidx.room.Dao import androidx.room.Database import androidx.room.Entity import androidx.room.Insert +import androidx.room.OnConflictStrategy import androidx.room.PrimaryKey import androidx.room.Query import androidx.room.RoomDatabase @@ -32,36 +33,32 @@ import org.oxycblt.auxio.music.info.Date import org.oxycblt.auxio.music.metadata.correctWhitespace import org.oxycblt.auxio.music.metadata.splitEscaped -@Database(entities = [CachedSong::class], version = 49, exportSchema = false) -abstract class CacheDatabase : RoomDatabase() { - abstract fun cachedSongsDao(): CachedSongsDao +@Database(entities = [Tags::class], version = 50, exportSchema = false) +abstract class TagDatabase : RoomDatabase() { + abstract fun cachedSongsDao(): TagDao } @Dao -interface CachedSongsDao { - @Query("SELECT * FROM CachedSong") suspend fun readSongs(): List +interface TagDao { + @Query("SELECT * FROM Tags WHERE uri = :uri AND dateModified = :dateModified") + suspend fun selectTags(uri: String, dateModified: Long): Tags? - @Query("DELETE FROM CachedSong") suspend fun nukeSongs() - - @Insert suspend fun insertSongs(songs: List) + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun updateTags(tags: Tags) } @Entity -@TypeConverters(CachedSong.Converters::class) -data class CachedSong( +@TypeConverters(Tags.Converters::class) +data class Tags( /** - * The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly - * unstable and should only be used for accessing the audio file. + * The Uri of the [RawSong]'s audio file, obtained from SAF. + * This should ideally be a black box only used for comparison. */ - @PrimaryKey var mediaStoreId: Long, - /** @see RawSong.dateAdded */ - var dateAdded: Long, + @PrimaryKey val uri: String, /** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */ - var dateModified: Long, - /** @see RawSong.size */ - var size: Long? = null, + val dateModified: Long, /** @see RawSong */ - var durationMs: Long, + val durationMs: Long, /** @see RawSong.replayGainTrackAdjustment */ val replayGainTrackAdjustment: Float? = null, /** @see RawSong.replayGainAlbumAdjustment */ @@ -110,7 +107,6 @@ data class CachedSong( rawSong.name = name rawSong.sortName = sortName - rawSong.size = size rawSong.durationMs = durationMs rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment @@ -154,16 +150,12 @@ data class CachedSong( companion object { fun fromRaw(rawSong: RawSong) = - CachedSong( - mediaStoreId = - requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" }, - dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" }, - dateModified = - requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" }, + Tags( + uri = rawSong.file.uri.toString(), + dateModified = rawSong.file.lastModified, musicBrainzId = rawSong.musicBrainzId, name = requireNotNull(rawSong.name) { "Invalid raw: No name" }, sortName = rawSong.sortName, - size = rawSong.size, durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }, replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment, replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment, diff --git a/app/src/main/java/org/oxycblt/auxio/music/fs/DeviceFiles.kt b/app/src/main/java/org/oxycblt/auxio/music/fs/DeviceFiles.kt index b1bfc1045..8e2d4df2c 100644 --- a/app/src/main/java/org/oxycblt/auxio/music/fs/DeviceFiles.kt +++ b/app/src/main/java/org/oxycblt/auxio/music/fs/DeviceFiles.kt @@ -77,7 +77,7 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex // rather than just being a glorified async. val lastModified = cursor.getLong(lastModifiedIndex) val size = cursor.getLong(sizeIndex) - emit(DeviceFile(childUri, mimeType, path, lastModified)) + emit(DeviceFile(childUri, mimeType, path, size, lastModified)) } } // Hypothetically, we could just emitAll as we recurse into a new directory, @@ -96,7 +96,7 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex DocumentsContract.Document.COLUMN_DISPLAY_NAME, DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_SIZE, - DocumentsContract.Document.COLUMN_LAST_MODIFIED, + DocumentsContract.Document.COLUMN_LAST_MODIFIED ) } } diff --git a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt index 9914dbe5f..43872923e 100644 --- a/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt +++ b/app/src/test/java/org/oxycblt/auxio/music/cache/CacheRepositoryTest.kt @@ -38,7 +38,7 @@ class CacheRepositoryTest { @Test fun cache_read_noInvalidate() { val dao = - mockk { + mockk { coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) } val cacheRepository = CacheRepositoryImpl(dao) @@ -62,7 +62,7 @@ class CacheRepositoryTest { @Test fun cache_read_invalidate() { val dao = - mockk { + mockk { coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) } val cacheRepository = CacheRepositoryImpl(dao) @@ -86,7 +86,7 @@ class CacheRepositoryTest { @Test fun cache_read_crashes() { - val dao = mockk { coEvery { readSongs() } throws IllegalStateException() } + val dao = mockk { coEvery { readSongs() } throws IllegalStateException() } val cacheRepository = CacheRepositoryImpl(dao) assertEquals(null, runBlocking { cacheRepository.readCache() }) coVerifyAll { dao.readSongs() } @@ -94,10 +94,10 @@ class CacheRepositoryTest { @Test fun cache_write() { - var currentlyStoredSongs = listOf() - val insertSongsArg = slot>() + var currentlyStoredSongs = listOf() + val insertSongsArg = slot>() val dao = - mockk { + mockk { coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() } coEvery { insertSongs(capture(insertSongsArg)) } answers @@ -122,7 +122,7 @@ class CacheRepositoryTest { @Test fun cache_write_nukeCrashes() { val dao = - mockk { + mockk { coEvery { nukeSongs() } throws IllegalStateException() coEvery { insertSongs(listOf()) } just Runs } @@ -134,7 +134,7 @@ class CacheRepositoryTest { @Test fun cache_write_insertCrashes() { val dao = - mockk { + mockk { coEvery { nukeSongs() } just Runs coEvery { insertSongs(listOf()) } throws IllegalStateException() } @@ -148,7 +148,7 @@ class CacheRepositoryTest { private companion object { val CACHED_SONG_A = - CachedSong( + Tags( mediaStoreId = 0, dateAdded = 1, dateModified = 2, @@ -206,7 +206,7 @@ class CacheRepositoryTest { ) val CACHED_SONG_B = - CachedSong( + Tags( mediaStoreId = 9, dateAdded = 10, dateModified = 11,