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

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
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.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,

View file

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

View file

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