DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156
@@ -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