music: introduce saf tag cache
This commit is contained in:
parent
53d0dbd0cb
commit
01a5e87a77
6 changed files with 70 additions and 165 deletions
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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 <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
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<RawSong>)
|
||||
}
|
||||
|
||||
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<RawSong>) {
|
||||
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<CachedSong>) : 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
|
||||
}
|
||||
}
|
|
@ -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<DeviceFile>): Flow<TagResult>
|
||||
suspend fun write(rawSongs: Flow<RawSong>)
|
||||
}
|
||||
|
||||
class TagCacheImpl @Inject constructor(
|
||||
private val tagDao: TagDao
|
||||
) : TagCache {
|
||||
override fun read(files: Flow<DeviceFile>) =
|
||||
files.transform<DeviceFile, TagResult> { 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<RawSong>) {
|
||||
rawSongs.collect { rawSong ->
|
||||
tagDao.updateTags(Tags.fromRaw(rawSong))
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<CachedSong>
|
||||
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<CachedSong>)
|
||||
@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,
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -38,7 +38,7 @@ class CacheRepositoryTest {
|
|||
@Test
|
||||
fun cache_read_noInvalidate() {
|
||||
val dao =
|
||||
mockk<CachedSongsDao> {
|
||||
mockk<TagDao> {
|
||||
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<CachedSongsDao> {
|
||||
mockk<TagDao> {
|
||||
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<CachedSongsDao> { coEvery { readSongs() } throws IllegalStateException() }
|
||||
val dao = mockk<TagDao> { 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<CachedSong>()
|
||||
val insertSongsArg = slot<List<CachedSong>>()
|
||||
var currentlyStoredSongs = listOf<Tags>()
|
||||
val insertSongsArg = slot<List<Tags>>()
|
||||
val dao =
|
||||
mockk<CachedSongsDao> {
|
||||
mockk<TagDao> {
|
||||
coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() }
|
||||
|
||||
coEvery { insertSongs(capture(insertSongsArg)) } answers
|
||||
|
@ -122,7 +122,7 @@ class CacheRepositoryTest {
|
|||
@Test
|
||||
fun cache_write_nukeCrashes() {
|
||||
val dao =
|
||||
mockk<CachedSongsDao> {
|
||||
mockk<TagDao> {
|
||||
coEvery { nukeSongs() } throws IllegalStateException()
|
||||
coEvery { insertSongs(listOf()) } just Runs
|
||||
}
|
||||
|
@ -134,7 +134,7 @@ class CacheRepositoryTest {
|
|||
@Test
|
||||
fun cache_write_insertCrashes() {
|
||||
val dao =
|
||||
mockk<CachedSongsDao> {
|
||||
mockk<TagDao> {
|
||||
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,
|
||||
|
|
Loading…
Reference in a new issue