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
|
@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()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
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.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,
|
|
@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
Loading…
Reference in a new issue