From 7ec8a82871219e39fe3dfa34d4edd8127f7fda20 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 23 Oct 2026 15:16:36 -0400 Subject: [PATCH] feat(android): the audio cache keeps a Liked songs budget and a Recently played budget (#6307) SimpleCache ran one LRU evictor at the rolling cap, so liked audio was dropped like anything else and the offline Liked pool shrank as other music played. TwoBucketCacheEvictor replaces it: - a span counts against Liked when its track is liked, else Recently played; a liked track that is also played is charged to Liked only; - each budget has its own cap (0 = unlimited) and drops its own least-recently-used audio first; - CacheBudgetSync feeds it the signed-in user's liked ids and the Storage caps, so a like, an unlike or a cap change applies at once; - nothing is evicted until the liked set is known, so startup never trims liked audio as Recently played. The decisions live in a Media3-free BucketLedger with unit tests. CacheConfig and PlayerModule, which only snapshotted the caps at launch, are gone. Co-Authored-By: Claude Opus 5.5 --- .../minstrel/MinstrelApplication.kt | 9 + .../minstrel/cache/audiocache/BucketLedger.kt | 122 ++++++++++++++ .../cache/audiocache/CacheBudgetSync.kt | 40 +++++ .../minstrel/cache/audiocache/CacheConfig.kt | 23 --- .../cache/audiocache/CacheSettings.kt | 12 +- .../cache/audiocache/TwoBucketCacheEvictor.kt | 112 +++++++++++++ .../minstrel/likes/data/LikesRepository.kt | 18 ++ .../minstrel/player/PlayerFactory.kt | 13 +- .../minstrel/player/PlayerModule.kt | 43 ----- .../cache/audiocache/BucketLedgerTest.kt | 155 ++++++++++++++++++ 10 files changed, 468 insertions(+), 79 deletions(-) create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/BucketLedger.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheBudgetSync.kt delete mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt create mode 100644 android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/TwoBucketCacheEvictor.kt delete mode 100644 android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt create mode 100644 android/app/src/test/java/com/fabledsword/minstrel/cache/audiocache/BucketLedgerTest.kt diff --git a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt index 8229d0e8..0ca637e9 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/MinstrelApplication.kt @@ -8,6 +8,7 @@ import coil3.SingletonImageLoader import coil3.network.okhttp.OkHttpNetworkFetcherFactory import coil3.request.crossfade import com.fabledsword.minstrel.cache.CacheIndexer +import com.fabledsword.minstrel.cache.audiocache.CacheBudgetSync import com.fabledsword.minstrel.cache.mutations.MutationReplayer import com.fabledsword.minstrel.cache.sync.SyncController import com.fabledsword.minstrel.di.ApplicationScope @@ -120,6 +121,14 @@ class MinstrelApplication : */ @Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer + /** + * Same construct-the-singleton trick — CacheBudgetSync's init block + * feeds the audio cache's evictor the liked set and the Storage caps. + * Without this @Inject the evictor would never arm, and the cache + * would grow without a limit. + */ + @Suppress("unused") @Inject lateinit var cacheBudgetSync: CacheBudgetSync + /** * Same construct-the-singleton trick — AudioPrefetcher's init * block subscribes to PlayerController.uiState + diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/BucketLedger.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/BucketLedger.kt new file mode 100644 index 00000000..ce8647c5 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/BucketLedger.kt @@ -0,0 +1,122 @@ +package com.fabledsword.minstrel.cache.audiocache + +import java.util.TreeSet + +/** Which budget a cached track's bytes count against. */ +enum class CacheBucket { LIKED, RECENT } + +/** + * The bookkeeping behind [TwoBucketCacheEvictor]: which budget each cached + * span counts against, how full each budget is, and which span to drop + * next. It knows nothing of Media3, so it is tested directly. + * + * A span belongs to [CacheBucket.LIKED] when its key (the track id) is in + * the liked set, and to [CacheBucket.RECENT] otherwise. A track that is + * liked and also recently played is therefore charged to Liked only; the + * two budgets never count the same bytes. Each bucket drops its own + * least-recently-used span first, so filling one never evicts the other. + * + * Nothing is evicted until [setLiked] has been called once. Before then + * every span would read as Recently played, and trimming at the Recently + * played cap would throw away liked audio at startup. + * + * Not thread-safe; the evictor serialises every call. + */ +class BucketLedger( + private val keyOf: (S) -> String, + private val lengthOf: (S) -> Long, + lruOrder: Comparator, +) { + private val spans = mapOf( + CacheBucket.LIKED to TreeSet(lruOrder), + CacheBucket.RECENT to TreeSet(lruOrder), + ) + private val bytes = mutableMapOf(CacheBucket.LIKED to 0L, CacheBucket.RECENT to 0L) + private val spansByKey = HashMap>() + private val bucketByKey = HashMap() + private var liked: Set = emptySet() + private var armed = false + + /** Cap on Liked bytes; 0 means unlimited. */ + var likedCapBytes: Long = 0L + + /** Cap on Recently played bytes; 0 means unlimited. */ + var recentCapBytes: Long = 0L + + fun bucketOf(key: String): CacheBucket = + if (key in liked) CacheBucket.LIKED else CacheBucket.RECENT + + fun bytesIn(bucket: CacheBucket): Long = bytes.getValue(bucket) + + /** Bytes the Liked budget can still take; [Long.MAX_VALUE] when unlimited. */ + fun likedRoomBytes(): Long = when { + likedCapBytes <= 0L -> Long.MAX_VALUE + else -> (likedCapBytes - bytesIn(CacheBucket.LIKED)).coerceAtLeast(0L) + } + + fun add(span: S) { + val key = keyOf(span) + val bucket = bucketByKey[key] ?: bucketOf(key).also { bucketByKey[key] = it } + if (spans.getValue(bucket).add(span)) { + bytes[bucket] = bytesIn(bucket) + lengthOf(span) + spansByKey.getOrPut(key) { HashSet() }.add(span) + } + } + + fun remove(span: S) { + val key = keyOf(span) + val bucket = bucketByKey[key] ?: return + if (spans.getValue(bucket).remove(span)) { + bytes[bucket] = bytesIn(bucket) - lengthOf(span) + } + val forKey = spansByKey[key] ?: return + forKey.remove(span) + if (forKey.isEmpty()) { + spansByKey.remove(key) + bucketByKey.remove(key) + } + } + + /** + * Replace the liked set and move every cached track whose bucket + * changed: a newly liked track's bytes leave Recently played for Liked, + * an unliked track's go the other way. Arms eviction. + */ + fun setLiked(ids: Set) { + liked = ids + armed = true + val moves = bucketByKey.filter { (key, bucket) -> bucketOf(key) != bucket }.keys.toList() + for (key in moves) { + val from = bucketByKey.getValue(key) + val to = bucketOf(key) + for (span in spansByKey[key].orEmpty()) { + if (spans.getValue(from).remove(span)) { + bytes[from] = bytesIn(from) - lengthOf(span) + spans.getValue(to).add(span) + bytes[to] = bytesIn(to) + lengthOf(span) + } + } + bucketByKey[key] = to + } + } + + /** + * The span to drop next, or null when both budgets fit. [pendingKey] and + * [pendingBytes] describe a write about to start, so room is made for it + * in its own bucket first. Recently played is trimmed before Liked. + */ + fun nextEviction(pendingKey: String? = null, pendingBytes: Long = 0L): S? { + if (!armed) return null + val pendingBucket = pendingKey?.let(::bucketOf) + return listOf(CacheBucket.RECENT, CacheBucket.LIKED).firstNotNullOfOrNull { bucket -> + val incoming = if (bucket == pendingBucket) pendingBytes.coerceAtLeast(0L) else 0L + oldestIfOver(bucket, incoming) + } + } + + private fun oldestIfOver(bucket: CacheBucket, incoming: Long): S? { + val cap = if (bucket == CacheBucket.LIKED) likedCapBytes else recentCapBytes + val over = cap > 0L && bytesIn(bucket) + incoming > cap + return if (over) spans.getValue(bucket).firstOrNull() else null + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheBudgetSync.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheBudgetSync.kt new file mode 100644 index 00000000..132d0969 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheBudgetSync.kt @@ -0,0 +1,40 @@ +package com.fabledsword.minstrel.cache.audiocache + +import com.fabledsword.minstrel.auth.AuthStore +import com.fabledsword.minstrel.di.ApplicationScope +import com.fabledsword.minstrel.likes.data.LikesRepository +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Keeps [TwoBucketCacheEvictor] in step with the signed-in user's liked + * tracks and the Storage caps. A like moves that track's cached audio into + * the Liked budget, an unlike moves it out, and a cap change trims at once. + * + * The liked set only flows once a user is signed in, and the evictor drops + * nothing until it has one, so liked audio is never trimmed as Recently + * played while the session is still loading. + * + * Eager-constructed via the construct-the-singleton trick in + * `MinstrelApplication`. + */ +@Singleton +class CacheBudgetSync @Inject constructor( + likes: LikesRepository, + authStore: AuthStore, + evictor: TwoBucketCacheEvictor, + @ApplicationScope scope: CoroutineScope, +) { + init { + scope.launch(Dispatchers.IO) { + combine(likes.observeSignedInLikedTrackIds(), authStore.cacheSettings, ::Pair) + .collect { (ids, settings) -> + evictor.update(ids, settings.likedCapBytes, settings.rollingCapBytes) + } + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt deleted file mode 100644 index b082bf65..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheConfig.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.fabledsword.minstrel.cache.audiocache - -/** - * Defaults for the audio cache; the user's Storage settings override them. - * - * - `likedCapBytes`: cap meant for a protected bucket of liked tracks' - * files. Nothing enforces it yet; SimpleCache evicts by the rolling - * cap alone. - * - `rollingCapBytes`: cap for incidental / auto-prefetch downloads, - * evicted least-recently-used first. - * - * The values are generous so first-time installs work well without - * configuration. - */ -data class CacheConfig( - val likedCapBytes: Long = DEFAULT_LIKED_CAP_BYTES, - val rollingCapBytes: Long = DEFAULT_ROLLING_CAP_BYTES, -) { - companion object { - const val DEFAULT_LIKED_CAP_BYTES: Long = 200L * 1024 * 1024 // 200 MiB - const val DEFAULT_ROLLING_CAP_BYTES: Long = 150L * 1024 * 1024 // 150 MiB - } -} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt index 32db9ce7..6099a25f 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/CacheSettings.kt @@ -9,12 +9,12 @@ private const val DEFAULT_PREFETCH_WINDOW = 5 * User-tunable audio cache settings. Persisted as a JSON * blob on the auth_session single-row table via [AuthStore]. * - * - [likedCapBytes]: budget for cached files of liked tracks. 0 means - * unlimited. Nothing enforces it yet: SimpleCache evicts by - * [rollingCapBytes] alone, and the Storage card hides this control. - * - [rollingCapBytes]: budget for incidental / auto-prefetch downloads. - * 0 means unlimited. Effective at next app start (SimpleCache is - * built once per process). + * - [likedCapBytes]: the Liked songs budget, for the cached audio of + * liked tracks. 0 means unlimited. + * - [rollingCapBytes]: the Recently played budget, for everything else + * streamed or prefetched. 0 means unlimited. + * A track that is liked and also recently played counts against Liked + * only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]). * - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher` * pre-downloads. * - [cacheLikedTracks]: when true, liking a track should also pin its diff --git a/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/TwoBucketCacheEvictor.kt b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/TwoBucketCacheEvictor.kt new file mode 100644 index 00000000..02036c29 --- /dev/null +++ b/android/app/src/main/java/com/fabledsword/minstrel/cache/audiocache/TwoBucketCacheEvictor.kt @@ -0,0 +1,112 @@ +package com.fabledsword.minstrel.cache.audiocache + +import androidx.media3.common.C +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheEvictor +import androidx.media3.datasource.cache.CacheSpan +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Bytes held against each audio-cache budget, and how much more the Liked + * budget can take ([Long.MAX_VALUE] when it is unlimited). + */ +data class CacheUsage(val likedBytes: Long, val recentBytes: Long, val likedRoomBytes: Long) + +/** + * The audio cache's eviction policy: two budgets, Liked songs and Recently + * played, each with its own cap and its own least-recently-used order (see + * [BucketLedger]). SimpleCache takes exactly one evictor, so this one + * carries both budgets. + * + * [CacheBudgetSync] feeds it the signed-in user's liked set and the caps + * from Storage settings, so a like, an unlike or a cap change applies at + * once, without restarting the app. + * + * Locking: SimpleCache calls every [CacheEvictor] method while holding its + * own monitor. Outside calls take the cache's monitor first and the + * ledger's second, the same order as those callbacks, so the two never + * deadlock. + */ +@Singleton +class TwoBucketCacheEvictor @Inject constructor() : CacheEvictor { + private val ledger = BucketLedger( + keyOf = { it.key }, + lengthOf = { it.length }, + lruOrder = compareBy { it.lastTouchTimestamp }.then(naturalOrder()), + ) + + @Volatile private var cache: Cache? = null + + /** Called once the SimpleCache exists, so updates can trim it. */ + fun attach(cache: Cache) { + this.cache = cache + } + + /** Apply a new liked set and caps, then trim whatever no longer fits. */ + fun update(likedIds: Set, likedCapBytes: Long, recentCapBytes: Long) { + val c = cache + val apply = { + ledger.likedCapBytes = likedCapBytes + ledger.recentCapBytes = recentCapBytes + ledger.setLiked(likedIds) + } + if (c == null) { + synchronized(ledger) { apply() } + } else { + synchronized(c) { + synchronized(ledger) { + apply() + evict(c, pendingKey = null, pendingBytes = 0L) + } + } + } + } + + fun usage(): CacheUsage = synchronized(ledger) { + CacheUsage( + likedBytes = ledger.bytesIn(CacheBucket.LIKED), + recentBytes = ledger.bytesIn(CacheBucket.RECENT), + likedRoomBytes = ledger.likedRoomBytes(), + ) + } + + override fun requiresCacheSpanTouches(): Boolean = true + + override fun onCacheInitialized() = Unit + + override fun onStartFile(cache: Cache, key: String, position: Long, length: Long) { + synchronized(ledger) { + evict(cache, key, if (length == C.LENGTH_UNSET.toLong()) 0L else length) + } + } + + override fun onSpanAdded(cache: Cache, span: CacheSpan) { + synchronized(ledger) { + ledger.add(span) + evict(cache, pendingKey = null, pendingBytes = 0L) + } + } + + override fun onSpanRemoved(cache: Cache, span: CacheSpan) { + synchronized(ledger) { ledger.remove(span) } + } + + override fun onSpanTouched(cache: Cache, oldSpan: CacheSpan, newSpan: CacheSpan) { + synchronized(ledger) { + ledger.remove(oldSpan) + ledger.add(newSpan) + evict(cache, pendingKey = null, pendingBytes = 0L) + } + } + + private fun evict(cache: Cache, pendingKey: String?, pendingBytes: Long) { + while (true) { + val span = ledger.nextEviction(pendingKey, pendingBytes) ?: return + cache.removeSpan(span) + // removeSpan reports back through onSpanRemoved; removing here as + // well guarantees the loop advances even if it did not. + ledger.remove(span) + } + } +} diff --git a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt index ba215019..23f4dc36 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/likes/data/LikesRepository.kt @@ -14,8 +14,12 @@ import com.fabledsword.minstrel.models.AlbumRef import com.fabledsword.minstrel.models.ArtistRef import com.fabledsword.minstrel.models.TrackRef import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import retrofit2.Retrofit @@ -117,6 +121,20 @@ class LikesRepository @Inject constructor( fun observeLikedTrackIds(): Flow> = likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() } + /** + * The liked track ids of whoever is signed in, following sign-in and + * account changes. Emits nothing while signed out, so a long-lived + * collector never mistakes the anonymous empty set for "no likes". + */ + @OptIn(ExperimentalCoroutinesApi::class) + fun observeSignedInLikedTrackIds(): Flow> = + authController.currentUser + .map { it?.id } + .distinctUntilChanged() + .filterNotNull() + .flatMapLatest { userId -> likeDao.observeLikedIdsOfType(userId, ENTITY_TRACK) } + .map { it.toSet() } + /** One-shot snapshot of the liked track-id set — for the offline pool filter. */ suspend fun likedTrackIds(): Set = likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet() diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt index 23d95acf..6e275df3 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerFactory.kt @@ -10,7 +10,6 @@ import androidx.media3.database.StandaloneDatabaseProvider import androidx.media3.datasource.DataSourceBitmapLoader import androidx.media3.datasource.cache.CacheDataSink import androidx.media3.datasource.cache.CacheDataSource -import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor import androidx.media3.datasource.cache.SimpleCache import androidx.media3.datasource.okhttp.OkHttpDataSource import androidx.media3.exoplayer.DefaultRenderersFactory @@ -20,7 +19,7 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink import androidx.media3.exoplayer.source.DefaultMediaSourceFactory import androidx.media3.session.CacheBitmapLoader import com.fabledsword.minstrel.auth.AuthStore -import com.fabledsword.minstrel.cache.audiocache.CacheConfig +import com.fabledsword.minstrel.cache.audiocache.TwoBucketCacheEvictor import com.fabledsword.minstrel.player.gain.GainAudioProcessor import com.fabledsword.minstrel.player.gain.ReplayGainStore import com.fabledsword.minstrel.player.output.ActiveUpnpHolder @@ -42,8 +41,8 @@ import javax.inject.Singleton * * `simpleCache` is exposed as a val for what needs the on-disk bytes: * the cached-track ids, the offline pools, the prefetcher and the - * Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the - * rolling cap; there is no separate liked-track bucket yet. + * Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked + * songs and Recently played. * * `build()` returns a [MinstrelForwardingPlayer] wrapping the internal * ExoPlayer. When the UPnP route drops (the poll loop's consecutive- @@ -56,7 +55,7 @@ import javax.inject.Singleton class PlayerFactory @Inject constructor( @ApplicationContext private val context: Context, private val okHttpClient: OkHttpClient, - private val cacheConfig: CacheConfig, + private val cacheEvictor: TwoBucketCacheEvictor, private val activeUpnpHolder: ActiveUpnpHolder, private val remoteState: RemotePlayerState, private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController, @@ -68,9 +67,9 @@ class PlayerFactory @Inject constructor( val simpleCache: SimpleCache = SimpleCache( cacheDir, - LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes), + cacheEvictor, StandaloneDatabaseProvider(context), - ) + ).also(cacheEvictor::attach) // MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst // of drop events (rapid SOAP failures during a single tear-down) surfaces diff --git a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt b/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt deleted file mode 100644 index b4bd42e2..00000000 --- a/android/app/src/main/java/com/fabledsword/minstrel/player/PlayerModule.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.fabledsword.minstrel.player - -import com.fabledsword.minstrel.auth.AuthStore -import com.fabledsword.minstrel.cache.audiocache.CacheConfig -import dagger.Module -import dagger.Provides -import dagger.hilt.InstallIn -import dagger.hilt.components.SingletonComponent -import javax.inject.Singleton - -/** - * Player + audio-cache wiring. PlayerFactory has @Inject so it doesn't - * need a @Provides here; this module provides the configuration values - * (CacheConfig) it depends on, and any player-related singletons that - * aren't @Inject-constructible. - * - * MinstrelPlayerService injects PlayerFactory and calls `build()` from - * `onCreate`. - */ -@Module -@InstallIn(SingletonComponent::class) -object PlayerModule { - - /** - * CacheConfig snapshot from the user's persisted CacheSettings. - * SimpleCache is constructed once per process, so changes via the - * Settings UI take effect on next app launch (not mid-flight). - * AuthStore's StateFlow hydrates async from Room — when the - * factory @Provides runs, the value is either the persisted - * setting (if Room finished first) or CacheSettings.DEFAULT. - * Worst case the user's tuning lands on next-next launch, which - * is acceptable for a single-user device pref. - */ - @Provides - @Singleton - fun provideCacheConfig(authStore: AuthStore): CacheConfig { - val s = authStore.cacheSettings.value - return CacheConfig( - likedCapBytes = s.likedCapBytes, - rollingCapBytes = s.rollingCapBytes, - ) - } -} diff --git a/android/app/src/test/java/com/fabledsword/minstrel/cache/audiocache/BucketLedgerTest.kt b/android/app/src/test/java/com/fabledsword/minstrel/cache/audiocache/BucketLedgerTest.kt new file mode 100644 index 00000000..5790abb0 --- /dev/null +++ b/android/app/src/test/java/com/fabledsword/minstrel/cache/audiocache/BucketLedgerTest.kt @@ -0,0 +1,155 @@ +package com.fabledsword.minstrel.cache.audiocache + +import org.junit.jupiter.api.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +/** + * The two audio-cache budgets: Liked songs and Recently played. A track + * that is liked and also played counts against Liked only, and each budget + * drops its own least-recently-used audio first. + */ +class BucketLedgerTest { + private data class Span(val key: String, val position: Long, val length: Long, val touched: Long) + + private fun ledger(likedCap: Long = 0L, recentCap: Long = 0L) = BucketLedger( + keyOf = { it.key }, + lengthOf = { it.length }, + lruOrder = compareBy { it.touched }.thenBy { it.key }.thenBy { it.position }, + ).apply { + likedCapBytes = likedCap + recentCapBytes = recentCap + } + + /** Drop spans the way the evictor does, until both budgets fit. */ + private fun BucketLedger.drain(): List { + val dropped = mutableListOf() + while (true) { + val span = nextEviction() ?: return dropped + remove(span) + dropped += span + } + } + + @Test + fun `a liked track's bytes count against Liked only`() { + val l = ledger() + l.setLiked(setOf("liked")) + l.add(Span("liked", 0, 300, touched = 1)) + l.add(Span("other", 0, 200, touched = 2)) + + assertEquals(300, l.bytesIn(CacheBucket.LIKED)) + assertEquals(200, l.bytesIn(CacheBucket.RECENT)) + } + + @Test + fun `filling Recently played drops its oldest audio and never liked audio`() { + val l = ledger(likedCap = 1_000, recentCap = 250) + l.setLiked(setOf("liked")) + val liked = Span("liked", 0, 100, touched = 0) + val oldest = Span("a", 0, 100, touched = 1) + l.add(liked) + l.add(oldest) + l.add(Span("b", 0, 100, touched = 2)) + l.add(Span("c", 0, 100, touched = 3)) + + assertEquals(listOf(oldest), l.drain()) + assertEquals(200, l.bytesIn(CacheBucket.RECENT)) + assertEquals(100, l.bytesIn(CacheBucket.LIKED)) + } + + @Test + fun `filling Liked drops its least recently played liked audio`() { + val l = ledger(likedCap = 150, recentCap = 1_000) + l.setLiked(setOf("x", "y")) + val older = Span("x", 0, 100, touched = 1) + l.add(older) + l.add(Span("y", 0, 100, touched = 2)) + l.add(Span("recent", 0, 500, touched = 0)) + + assertEquals(listOf(older), l.drain()) + assertEquals(500, l.bytesIn(CacheBucket.RECENT)) + } + + @Test + fun `liking a cached track moves its bytes into Liked`() { + val l = ledger(likedCap = 1_000, recentCap = 150) + l.setLiked(emptySet()) + l.add(Span("t", 0, 100, touched = 1)) + l.add(Span("t", 100, 50, touched = 2)) + l.add(Span("u", 0, 100, touched = 3)) + assertEquals(250, l.bytesIn(CacheBucket.RECENT)) + + l.setLiked(setOf("t")) + + assertEquals(150, l.bytesIn(CacheBucket.LIKED)) + assertEquals(100, l.bytesIn(CacheBucket.RECENT)) + assertEquals(emptyList(), l.drain()) + } + + @Test + fun `unliking moves the bytes to Recently played, which then trims`() { + val l = ledger(likedCap = 1_000, recentCap = 150) + l.setLiked(setOf("t")) + val unliked = Span("t", 0, 100, touched = 1) + l.add(unliked) + l.add(Span("u", 0, 100, touched = 2)) + + l.setLiked(emptySet()) + + assertEquals(listOf(unliked), l.drain()) + assertEquals(0, l.bytesIn(CacheBucket.LIKED)) + } + + @Test + fun `shrinking a cap trims at once`() { + val l = ledger(recentCap = 1_000) + l.setLiked(emptySet()) + l.add(Span("a", 0, 100, touched = 1)) + l.add(Span("b", 0, 100, touched = 2)) + assertEquals(emptyList(), l.drain()) + + l.recentCapBytes = 100 + + assertEquals(listOf("a"), l.drain().map { it.key }) + } + + @Test + fun `a cap of 0 never evicts`() { + val l = ledger(likedCap = 0, recentCap = 0) + l.setLiked(setOf("liked")) + repeat(50) { l.add(Span("k$it", 0, 1_000_000, touched = it.toLong())) } + l.add(Span("liked", 0, 1_000_000, touched = 99)) + + assertNull(l.nextEviction()) + } + + @Test + fun `nothing is evicted before the liked set is known`() { + val l = ledger(likedCap = 100, recentCap = 100) + l.add(Span("maybe-liked", 0, 500, touched = 1)) + + assertNull(l.nextEviction()) + } + + @Test + fun `a write about to start makes room in its own bucket`() { + val l = ledger(likedCap = 1_000, recentCap = 200) + l.setLiked(setOf("liked")) + l.add(Span("a", 0, 150, touched = 1)) + + assertNull(l.nextEviction(pendingKey = "liked", pendingBytes = 100)) + assertEquals("a", l.nextEviction(pendingKey = "new", pendingBytes = 100)?.key) + } + + @Test + fun `Liked room is what the Liked cap has left`() { + val l = ledger(likedCap = 500) + l.setLiked(setOf("x")) + l.add(Span("x", 0, 200, touched = 1)) + + assertEquals(300, l.likedRoomBytes()) + l.likedCapBytes = 0 + assertEquals(Long.MAX_VALUE, l.likedRoomBytes()) + } +}