musikr: refactor cache api

To make the pruning system more agnostic and "extendable"
This commit is contained in:
Alexander Capehart 2024-12-26 13:58:23 -05:00
parent 3f364dc5c6
commit 61fd11fe04
No known key found for this signature in database
GPG key ID: 37DBE3621FE9AD47
6 changed files with 108 additions and 76 deletions

View file

@ -362,7 +362,7 @@ constructor(
val currentRevision = musicSettings.revision
val newRevision = currentRevision?.takeIf { withCache } ?: UUID.randomUUID()
val cache = if (withCache) storedCache.full() else storedCache.writeOnly()
val cache = if (withCache) storedCache.visible() else storedCache.invisible()
val covers = MutableRevisionedStoredCovers(context, newRevision)
val storage = Storage(cache, covers, storedPlaylists)
val interpretation = Interpretation(nameFactory, separators)

View file

@ -25,7 +25,7 @@ import org.oxycblt.musikr.tag.interpret.Naming
import org.oxycblt.musikr.tag.interpret.Separators
data class Storage(
val cache: Cache,
val cache: Cache.Factory,
val storedCovers: MutableStoredCovers,
val storedPlaylists: StoredPlaylists
)

View file

@ -18,28 +18,19 @@
package org.oxycblt.musikr.cache
import android.content.Context
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
abstract class Cache {
internal abstract fun lap(): Long
internal abstract suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult
internal abstract suspend fun write(song: RawSong)
internal abstract suspend fun prune(timestamp: Long)
}
internal abstract suspend fun finalize()
interface StoredCache {
fun full(): Cache
fun writeOnly(): Cache
companion object {
fun from(context: Context): StoredCache = StoredCacheImpl(CacheDatabase.from(context))
abstract class Factory {
internal abstract fun open(): Cache
}
}
@ -48,48 +39,3 @@ internal sealed interface CacheResult {
data class Miss(val file: DeviceFile, val addedMs: Long?) : CacheResult
}
private class StoredCacheImpl(private val database: CacheDatabase) : StoredCache {
override fun full() = CacheImpl(database.cachedSongsDao())
override fun writeOnly() = WriteOnlyCacheImpl(database.cachedSongsDao())
}
private class CacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() {
override fun lap() = System.nanoTime()
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult {
val song = cacheInfoDao.selectSong(file.uri.toString()) ?:
return CacheResult.Miss(file, null)
if (song.modifiedMs != file.lastModified) {
// 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))
}
override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
override suspend fun prune(timestamp: Long) {
cacheInfoDao.pruneOlderThan(timestamp)
}
}
private class WriteOnlyCacheImpl(private val cacheInfoDao: CacheInfoDao) : Cache() {
override fun lap() = System.nanoTime()
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) =
CacheResult.Miss(file, cacheInfoDao.selectAddedMs(file.uri.toString()))
override suspend fun write(song: RawSong) =
cacheInfoDao.updateSong(CachedSong.fromRawSong(song))
override suspend fun prune(timestamp: Long) {
cacheInfoDao.pruneOlderThan(timestamp)
}
}

View file

@ -44,7 +44,11 @@ import org.oxycblt.musikr.util.splitEscaped
@Database(entities = [CachedSong::class], version = 50, exportSchema = false)
internal abstract class CacheDatabase : RoomDatabase() {
abstract fun cachedSongsDao(): CacheInfoDao
abstract fun visibleDao(): VisibleCacheDao
abstract fun invisibleDao(): InvisibleCacheDao
abstract fun writeDao(): CacheWriteDao
companion object {
fun from(context: Context) =
@ -56,7 +60,7 @@ internal abstract class CacheDatabase : RoomDatabase() {
}
@Dao
internal interface CacheInfoDao {
internal interface VisibleCacheDao {
@Query("SELECT * FROM CachedSong WHERE uri = :uri")
suspend fun selectSong(uri: String): CachedSong?
@ -68,7 +72,16 @@ internal interface CacheInfoDao {
@Query("UPDATE cachedsong SET touchedNs = :nowNs WHERE uri = :uri")
suspend fun updateTouchedNs(uri: String, nowNs: Long)
}
@Dao
internal interface InvisibleCacheDao {
@Query("SELECT addedMs FROM CachedSong WHERE uri = :uri")
suspend fun selectAddedMs(uri: String): Long?
}
@Dao
internal interface CacheWriteDao {
@Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun updateSong(cachedSong: CachedSong)
@Query("DELETE FROM CachedSong WHERE touchedNs < :now")

View file

@ -0,0 +1,70 @@
package org.oxycblt.musikr.cache
import android.content.Context
import org.oxycblt.musikr.cover.StoredCovers
import org.oxycblt.musikr.fs.DeviceFile
import org.oxycblt.musikr.pipeline.RawSong
interface StoredCache {
fun visible(): Cache.Factory
fun invisible(): Cache.Factory
companion object {
fun from(context: Context): StoredCache = StoredCacheImpl(CacheDatabase.from(context))
}
}
private class StoredCacheImpl(private val cacheDatabase: CacheDatabase) : StoredCache {
override fun visible(): Cache.Factory = VisibleStoredCache.Factory(cacheDatabase)
override fun invisible(): Cache.Factory = InvisibleStoredCache.Factory(cacheDatabase)
}
private abstract class BaseStoredCache(protected val writeDao: CacheWriteDao) : Cache() {
private val created = System.nanoTime()
override suspend fun write(song: RawSong) =
writeDao.updateSong(CachedSong.fromRawSong(song))
override suspend fun finalize() {
// Anything not create during this cache's use implies that it has not been
// access during this run and should be pruned.
writeDao.pruneOlderThan(created)
}
}
private class VisibleStoredCache(
private val visibleDao: VisibleCacheDao,
writeDao: CacheWriteDao
) : BaseStoredCache(writeDao) {
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers): CacheResult {
val song =
visibleDao.selectSong(file.uri.toString()) ?: return CacheResult.Miss(file, null)
if (song.modifiedMs != file.lastModified) {
// 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.
visibleDao.touch(file.uri.toString())
return CacheResult.Hit(song.intoRawSong(file, storedCovers))
}
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
override fun open() = VisibleStoredCache(cacheDatabase.visibleDao(), cacheDatabase.writeDao())
}
}
private class InvisibleStoredCache(
private val invisibleCacheDao: InvisibleCacheDao,
writeDao: CacheWriteDao
) : BaseStoredCache(writeDao) {
override suspend fun read(file: DeviceFile, storedCovers: StoredCovers) =
CacheResult.Miss(file, invisibleCacheDao.selectAddedMs(file.uri.toString()))
class Factory(private val cacheDatabase: CacheDatabase) : Cache.Factory() {
override fun open() = InvisibleStoredCache(cacheDatabase.invisibleDao(), cacheDatabase.writeDao())
}
}

View file

@ -15,7 +15,7 @@
* 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.musikr.pipeline
import android.content.Context
@ -52,7 +52,8 @@ internal interface ExtractStep {
MetadataExtractor.new(),
TagParser.new(),
storage.cache,
storage.storedCovers)
storage.storedCovers
)
}
}
@ -60,11 +61,11 @@ private class ExtractStepImpl(
private val context: Context,
private val metadataExtractor: MetadataExtractor,
private val tagParser: TagParser,
private val cache: Cache,
private val cacheFactory: Cache.Factory,
private val storedCovers: MutableStoredCovers
) : ExtractStep {
override fun extract(nodes: Flow<ExploreNode>): Flow<ExtractedMusic> {
val cacheTimestamp = cache.lap()
val cache = cacheFactory.open()
val addingMs = System.currentTimeMillis()
val filterFlow =
nodes.divert {
@ -113,13 +114,13 @@ private class ExtractStepImpl(
val metadata =
fds.mapNotNull { fileWith ->
wrap(fileWith.file) { _ ->
metadataExtractor
.extract(fileWith.with)
?.let { FileWith(fileWith.file, it) }
.also { withContext(Dispatchers.IO) { fileWith.with.close() } }
}
wrap(fileWith.file) { _ ->
metadataExtractor
.extract(fileWith.with)
?.let { FileWith(fileWith.file, it) }
.also { withContext(Dispatchers.IO) { fileWith.with.close() } }
}
}
.flowOn(Dispatchers.IO)
// Covers are pretty big, so cap the amount of parsed metadata in-memory to at most
// 8 to minimize GCs.
@ -148,18 +149,20 @@ private class ExtractStepImpl(
.buffer(Channel.UNLIMITED)
}
.flattenMerge()
.onCompletion {
cache.prune(cacheTimestamp)
}
return merge(
val merged = merge(
filterFlow.manager,
readDistributedFlow.manager,
cacheFlow.manager,
cachedSongs,
writeDistributedFlow.manager,
writtenSongs,
playlistNodes)
playlistNodes
)
return merged.onCompletion {
cache.finalize()
}
}
private data class FileWith<T>(val file: DeviceFile, val with: T)