musikr: add cache pruning

Helps remove dead entries, and additionally makes date added values more
accurate over time.s
This commit is contained in:
Alexander Capehart 2024-12-26 13:36:56 -05:00
parent 4f920e922d
commit 3f364dc5c6
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
4 changed files with 57 additions and 15 deletions

View file

@ -24,9 +24,13 @@ import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong import org.oxycblt.musikr.pipeline.RawSong
abstract class Cache { abstract class Cache {
internal abstract fun lap(): Long
internal abstract suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult internal abstract suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult
internal abstract suspend fun write(song: RawSong) internal abstract suspend fun write(song: RawSong)
internal abstract suspend fun prune(timestamp: Long)
} }
interface StoredCache { interface StoredCache {
@ -42,7 +46,7 @@ interface StoredCache {
internal sealed interface CacheResult { internal sealed interface CacheResult {
data class Hit(val song: RawSong) : CacheResult data class Hit(val song: RawSong) : CacheResult
data class Miss(val file: DeviceFile, val dateAdded: Long?) : CacheResult data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult
} }
private class StoredCacheImpl(private val database: CacheDatabase) : StoredCache { private class StoredCacheImpl(private val database: CacheDatabase) : StoredCache {
@ -52,23 +56,40 @@ private class StoredCacheImpl(private val database: CacheDatabase) : StoredCache
} }
private class CacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() { private class CacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() {
override fun lap() = System.nanoTime()
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult { override suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult {
val song = cacheInfoDao.selectSong(file.uri.toString()) ?: val song = cacheInfoDao.selectSong(file.uri.toString()) ?:
return CacheResult.Miss(file, null) return CacheResult.Miss(file, null)
if (song.dateModified != file.lastModified) { if (song.modifiedMs != file.lastModified) {
return CacheResult.Miss(file, song.dateAdded) // We *found* this file earlier, but it's out of date.
// Send back it with the timestamp so it will be re-used.
// The touch timestamp will be updated on write.
return CacheResult.Miss(file, song.addedMs)
} }
// Valid file, update the touch time.
cacheInfoDao.touch(file.uri.toString())
return CacheResult.Hit(song.intoRawSong(file, storedCovers)) return CacheResult.Hit(song.intoRawSong(file, storedCovers))
} }
override suspend fun write(song: RawSong) = override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song)) cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
override suspend fun prune(timestamp: Long) {
cacheInfoDao.pruneOlderThan(timestamp)
}
} }
private class WriteOnlyCacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() { private class WriteOnlyCacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() {
override fun lap() = System.nanoTime()
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) = override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) =
CacheResult.Miss(file, cacheInfoDao.selectDateAdded(file.uri.toString())) CacheResult.Miss(file, cacheInfoDao.selectAddedMs(file.uri.toString()))
override suspend fun write(song: RawSong) = override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song)) cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
override suspend fun prune(timestamp: Long) {
cacheInfoDao.pruneOlderThan(timestamp)
}
} }

View file

@ -21,6 +21,7 @@ package org.oxycblt.musikr.cache
import android.content.Context import android.content.Context
import androidx.room.Dao import androidx.room.Dao
import androidx.room.Database import androidx.room.Database
import androidx.room.Delete
import androidx.room.Entity import androidx.room.Entity
import androidx.room.Insert import androidx.room.Insert
import androidx.room.OnConflictStrategy import androidx.room.OnConflictStrategy
@ -28,8 +29,10 @@ import androidx.room.PrimaryKey
import androidx.room.Query import androidx.room.Query
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import androidx.room.Transaction
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import androidx.room.Update
import org.oxycblt.musikr.cover.StoredCovers import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.metadata.Properties import org.oxycblt.musikr.metadata.Properties
@ -57,18 +60,28 @@ internal interface CacheInfoDao {
@Query("SELECT * FROM CachedSong WHERE uri = :uri") @Query("SELECT * FROM CachedSong WHERE uri = :uri")
suspend fun selectSong(uri: String): CachedSong? suspend fun selectSong(uri: String): CachedSong?
@Query("SELECT dateAdded FROM CachedSong WHERE uri = :uri") @Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
suspend fun selectDateAdded(uri: String): Long? suspend fun selectAddedMs(uri: String): Long?
@Transaction
suspend fun touch(uri: String) = updateTouchedNs(uri, System.nanoTime())
@Query("UPDATE cachedsong SET touchedNs = :nowNs WHERE uri = :uri")
suspend fun updateTouchedNs(uri: String, nowNs: Long)
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong) @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong)
@Query("DELETE FROM CachedSong WHERE touchedNs < :now")
suspend fun pruneOlderThan(now: Long)
} }
@Entity @Entity
@TypeConverters(CachedSong.Converters::class) @TypeConverters(CachedSong.Converters::class)
internal data class CachedSong( internal data class CachedSong(
@PrimaryKey val uri: String, @PrimaryKey val uri: String,
val dateModified: Long, val modifiedMs: Long,
val dateAdded: Long, val addedMs: Long,
val touchedNs: Long,
val mimeType: String, val mimeType: String,
val durationMs: Long, val durationMs: Long,
val bitrateHz: Int, val bitrateHz: Int,
@ -122,7 +135,7 @@ internal data class CachedSong(
replayGainTrackAdjustment = replayGainTrackAdjustment, replayGainTrackAdjustment = replayGainTrackAdjustment,
replayGainAlbumAdjustment = replayGainAlbumAdjustment), replayGainAlbumAdjustment = replayGainAlbumAdjustment),
coverId?.let { storedCovers.obtain(it) }, coverId?.let { storedCovers.obtain(it) },
dateAdded = dateAdded) addedMs = addedMs)
object Converters { object Converters {
@TypeConverter @TypeConverter
@ -141,8 +154,11 @@ internal data class CachedSong(
fun fromRawSong(rawSong: RawSong) = fun fromRawSong(rawSong: RawSong) =
CachedSong( CachedSong(
uri = rawSong.file.uri.toString(), uri = rawSong.file.uri.toString(),
dateModified = rawSong.file.lastModified, modifiedMs = rawSong.file.lastModified,
dateAdded = rawSong.dateAdded, addedMs = rawSong.addedMs,
// Should be strictly monotonic so we don't prune this
// by accident later.
touchedNs = System.nanoTime(),
musicBrainzId = rawSong.tags.musicBrainzId, musicBrainzId = rawSong.tags.musicBrainzId,
name = rawSong.tags.name, name = rawSong.tags.name,
sortName = rawSong.tags.sortName, sortName = rawSong.tags.sortName,

View file

@ -28,6 +28,7 @@ import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.oxycblt.musikr.Storage import org.oxycblt.musikr.Storage
import org.oxycblt.musikr.cache.Cache import org.oxycblt.musikr.cache.Cache
@ -63,7 +64,8 @@ private class ExtractStepImpl(
private val storedCovers: MutableStoredCovers private val storedCovers: MutableStoredCovers
) : ExtractStep { ) : ExtractStep {
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> { override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val startTime = System.currentTimeMillis() val cacheTimestamp = cache.lap()
val addingMs = System.currentTimeMillis()
val filterFlow = val filterFlow =
nodes.divert { nodes.divert {
when (it) { when (it) {
@ -128,7 +130,7 @@ private class ExtractStepImpl(
.mapNotNull { fileWith -> .mapNotNull { fileWith ->
val tags = tagParser.parse(fileWith.file, fileWith.with) val tags = tagParser.parse(fileWith.file, fileWith.with)
val cover = fileWith.with.cover?.let { storedCovers.write(it) } val cover = fileWith.with.cover?.let { storedCovers.write(it) }
RawSong(fileWith.file, fileWith.with.properties, tags, cover, startTime) RawSong(fileWith.file, fileWith.with.properties, tags, cover, addingMs)
} }
.flowOn(Dispatchers.IO) .flowOn(Dispatchers.IO)
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
@ -146,6 +148,9 @@ private class ExtractStepImpl(
.buffer(Channel.UNLIMITED) .buffer(Channel.UNLIMITED)
} }
.flattenMerge() .flattenMerge()
.onCompletion {
cache.prune(cacheTimestamp)
}
return merge( return merge(
filterFlow.manager, filterFlow.manager,
@ -165,7 +170,7 @@ internal data class RawSong(
val properties: Properties, val properties: Properties,
val tags: ParsedTags, val tags: ParsedTags,
val cover: Cover?, val cover: Cover?,
val dateAdded: Long val addedMs: Long
) )
internal sealed interface ExtractedMusic { internal sealed interface ExtractedMusic {

View file

@ -65,7 +65,7 @@ private class TagInterpreterImpl(private val interpretation: Interpretation) : T
size = song.file.size, size = song.file.size,
format = Format.infer(song.file.mimeType, song.properties.mimeType), format = Format.infer(song.file.mimeType, song.properties.mimeType),
lastModified = song.file.lastModified, lastModified = song.file.lastModified,
dateAdded = song.dateAdded, dateAdded = song.addedMs,
musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull(), musicBrainzId = song.tags.musicBrainzId?.toUuidOrNull(),
name = interpretation.naming.name(song.tags.name, song.tags.sortName), name = interpretation.naming.name(song.tags.name, song.tags.sortName),
rawName = song.tags.name, rawName = song.tags.name,