feat(android): the audio cache keeps a Liked songs budget and a Recently played budget (#6307)
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 16s
release / web (push) Successful in 1m25s
release / go (push) Successful in 1m43s
release / integration (push) Successful in 5m15s
release / android (push) Successful in 5m23s
release / Build signed APK (releases and dev) (push) Successful in 5m33s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 14s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 +
|
||||
|
||||
+122
@@ -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<S : Any>(
|
||||
private val keyOf: (S) -> String,
|
||||
private val lengthOf: (S) -> Long,
|
||||
lruOrder: Comparator<S>,
|
||||
) {
|
||||
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<String, MutableSet<S>>()
|
||||
private val bucketByKey = HashMap<String, CacheBucket>()
|
||||
private var liked: Set<String> = 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<String>) {
|
||||
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
|
||||
}
|
||||
}
|
||||
+40
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-23
@@ -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
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -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
|
||||
|
||||
Vendored
+112
@@ -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<CacheSpan>(
|
||||
keyOf = { it.key },
|
||||
lengthOf = { it.length },
|
||||
lruOrder = compareBy<CacheSpan> { 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<String>, 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<Set<String>> =
|
||||
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<Set<String>> =
|
||||
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<String> =
|
||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+155
@@ -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<Span>(
|
||||
keyOf = { it.key },
|
||||
lengthOf = { it.length },
|
||||
lruOrder = compareBy<Span> { 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<Span>.drain(): List<Span> {
|
||||
val dropped = mutableListOf<Span>()
|
||||
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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user