music: introduce saf tag cache

This commit is contained in:
Alexander Capehart 2024-11-19 13:19:42 -07:00
parent 53d0dbd0cb
commit 01a5e87a77
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 70 additions and 165 deletions

View file

@ -30,20 +30,20 @@ import javax.inject.Singleton
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
interface CacheModule { interface TagCacheModule {
@Binds fun cacheRepository(cacheRepository: CacheRepositoryImpl): CacheRepository @Binds fun tagCache(cacheRepository: TagCacheImpl): TagCache
} }
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
class CacheRoomModule { class TagDatabaseModule {
@Singleton @Singleton
@Provides @Provides
fun database(@ApplicationContext context: Context) = fun database(@ApplicationContext context: Context) =
Room.databaseBuilder( Room.databaseBuilder(
context.applicationContext, CacheDatabase::class.java, "music_cache.db") context.applicationContext, TagDatabase::class.java, "music_cache.db")
.fallbackToDestructiveMigration() .fallbackToDestructiveMigration()
.build() .build()
@Provides fun cachedSongsDao(database: CacheDatabase) = database.cachedSongsDao() @Provides fun tagsDao(database: TagDatabase) = database.cachedSongsDao()
} }

View file

@ -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
}
}

View file

@ -1,2 +1,36 @@
package org.oxycblt.auxio.music.cache 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))
}
}
}

View file

@ -22,6 +22,7 @@ import androidx.room.Dao
import androidx.room.Database import androidx.room.Database
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.RoomDatabase 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.correctWhitespace
import org.oxycblt.auxio.music.metadata.splitEscaped import org.oxycblt.auxio.music.metadata.splitEscaped
@Database(entities = [CachedSong::class], version = 49, exportSchema = false) @Database(entities = [Tags::class], version = 50, exportSchema = false)
abstract class CacheDatabase : RoomDatabase() { abstract class TagDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CachedSongsDao abstract fun cachedSongsDao(): TagDao
} }
@Dao @Dao
interface CachedSongsDao { interface TagDao {
@Query("SELECT * FROM CachedSong") suspend fun readSongs(): List<CachedSong> @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(onConflict = OnConflictStrategy.REPLACE)
suspend fun updateTags(tags: Tags)
@Insert suspend fun insertSongs(songs: List<CachedSong>)
} }
@Entity @Entity
@TypeConverters(CachedSong.Converters::class) @TypeConverters(Tags.Converters::class)
data class CachedSong( data class Tags(
/** /**
* The ID of the [RawSong]'s audio file, obtained from MediaStore. Note that this ID is highly * The Uri of the [RawSong]'s audio file, obtained from SAF.
* unstable and should only be used for accessing the audio file. * This should ideally be a black box only used for comparison.
*/ */
@PrimaryKey var mediaStoreId: Long, @PrimaryKey val uri: String,
/** @see RawSong.dateAdded */
var dateAdded: Long,
/** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */ /** The latest date the [RawSong]'s audio file was modified, as a unix epoch timestamp. */
var dateModified: Long, val dateModified: Long,
/** @see RawSong.size */
var size: Long? = null,
/** @see RawSong */ /** @see RawSong */
var durationMs: Long, val durationMs: Long,
/** @see RawSong.replayGainTrackAdjustment */ /** @see RawSong.replayGainTrackAdjustment */
val replayGainTrackAdjustment: Float? = null, val replayGainTrackAdjustment: Float? = null,
/** @see RawSong.replayGainAlbumAdjustment */ /** @see RawSong.replayGainAlbumAdjustment */
@ -110,7 +107,6 @@ data class CachedSong(
rawSong.name = name rawSong.name = name
rawSong.sortName = sortName rawSong.sortName = sortName
rawSong.size = size
rawSong.durationMs = durationMs rawSong.durationMs = durationMs
rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment rawSong.replayGainTrackAdjustment = replayGainTrackAdjustment
@ -154,16 +150,12 @@ data class CachedSong(
companion object { companion object {
fun fromRaw(rawSong: RawSong) = fun fromRaw(rawSong: RawSong) =
CachedSong( Tags(
mediaStoreId = uri = rawSong.file.uri.toString(),
requireNotNull(rawSong.mediaStoreId) { "Invalid raw: No MediaStore ID" }, dateModified = rawSong.file.lastModified,
dateAdded = requireNotNull(rawSong.dateAdded) { "Invalid raw: No date added" },
dateModified =
requireNotNull(rawSong.dateModified) { "Invalid raw: No date modified" },
musicBrainzId = rawSong.musicBrainzId, musicBrainzId = rawSong.musicBrainzId,
name = requireNotNull(rawSong.name) { "Invalid raw: No name" }, name = requireNotNull(rawSong.name) { "Invalid raw: No name" },
sortName = rawSong.sortName, sortName = rawSong.sortName,
size = rawSong.size,
durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" }, durationMs = requireNotNull(rawSong.durationMs) { "Invalid raw: No duration" },
replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment, replayGainTrackAdjustment = rawSong.replayGainTrackAdjustment,
replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment, replayGainAlbumAdjustment = rawSong.replayGainAlbumAdjustment,

View file

@ -77,7 +77,7 @@ class DeviceFilesImpl @Inject constructor(@ApplicationContext private val contex
// rather than just being a glorified async. // rather than just being a glorified async.
val lastModified = cursor.getLong(lastModifiedIndex) val lastModified = cursor.getLong(lastModifiedIndex)
val size = cursor.getLong(sizeIndex) 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, // 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_DISPLAY_NAME,
DocumentsContract.Document.COLUMN_MIME_TYPE, DocumentsContract.Document.COLUMN_MIME_TYPE,
DocumentsContract.Document.COLUMN_SIZE, DocumentsContract.Document.COLUMN_SIZE,
DocumentsContract.Document.COLUMN_LAST_MODIFIED, DocumentsContract.Document.COLUMN_LAST_MODIFIED
) )
} }
} }

View file

@ -38,7 +38,7 @@ class CacheRepositoryTest {
@Test @Test
fun cache_read_noInvalidate() { fun cache_read_noInvalidate() {
val dao = val dao =
mockk<CachedSongsDao> { mockk<TagDao> {
coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B))
} }
val cacheRepository = CacheRepositoryImpl(dao) val cacheRepository = CacheRepositoryImpl(dao)
@ -62,7 +62,7 @@ class CacheRepositoryTest {
@Test @Test
fun cache_read_invalidate() { fun cache_read_invalidate() {
val dao = val dao =
mockk<CachedSongsDao> { mockk<TagDao> {
coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B)) coEvery { readSongs() }.returnsMany(listOf(CACHED_SONG_A, CACHED_SONG_B))
} }
val cacheRepository = CacheRepositoryImpl(dao) val cacheRepository = CacheRepositoryImpl(dao)
@ -86,7 +86,7 @@ class CacheRepositoryTest {
@Test @Test
fun cache_read_crashes() { fun cache_read_crashes() {
val dao = mockk<CachedSongsDao> { coEvery { readSongs() } throws IllegalStateException() } val dao = mockk<TagDao> { coEvery { readSongs() } throws IllegalStateException() }
val cacheRepository = CacheRepositoryImpl(dao) val cacheRepository = CacheRepositoryImpl(dao)
assertEquals(null, runBlocking { cacheRepository.readCache() }) assertEquals(null, runBlocking { cacheRepository.readCache() })
coVerifyAll { dao.readSongs() } coVerifyAll { dao.readSongs() }
@ -94,10 +94,10 @@ class CacheRepositoryTest {
@Test @Test
fun cache_write() { fun cache_write() {
var currentlyStoredSongs = listOf<CachedSong>() var currentlyStoredSongs = listOf<Tags>()
val insertSongsArg = slot<List<CachedSong>>() val insertSongsArg = slot<List<Tags>>()
val dao = val dao =
mockk<CachedSongsDao> { mockk<TagDao> {
coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() } coEvery { nukeSongs() } answers { currentlyStoredSongs = listOf() }
coEvery { insertSongs(capture(insertSongsArg)) } answers coEvery { insertSongs(capture(insertSongsArg)) } answers
@ -122,7 +122,7 @@ class CacheRepositoryTest {
@Test @Test
fun cache_write_nukeCrashes() { fun cache_write_nukeCrashes() {
val dao = val dao =
mockk<CachedSongsDao> { mockk<TagDao> {
coEvery { nukeSongs() } throws IllegalStateException() coEvery { nukeSongs() } throws IllegalStateException()
coEvery { insertSongs(listOf()) } just Runs coEvery { insertSongs(listOf()) } just Runs
} }
@ -134,7 +134,7 @@ class CacheRepositoryTest {
@Test @Test
fun cache_write_insertCrashes() { fun cache_write_insertCrashes() {
val dao = val dao =
mockk<CachedSongsDao> { mockk<TagDao> {
coEvery { nukeSongs() } just Runs coEvery { nukeSongs() } just Runs
coEvery { insertSongs(listOf()) } throws IllegalStateException() coEvery { insertSongs(listOf()) } throws IllegalStateException()
} }
@ -148,7 +148,7 @@ class CacheRepositoryTest {
private companion object { private companion object {
val CACHED_SONG_A = val CACHED_SONG_A =
CachedSong( Tags(
mediaStoreId = 0, mediaStoreId = 0,
dateAdded = 1, dateAdded = 1,
dateModified = 2, dateModified = 2,
@ -206,7 +206,7 @@ class CacheRepositoryTest {
) )
val CACHED_SONG_B = val CACHED_SONG_B =
CachedSong( Tags(
mediaStoreId = 9, mediaStoreId = 9,
dateAdded = 10, dateAdded = 10,
dateModified = 11, dateModified = 11,