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.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
import coil3.request.crossfade
|
import coil3.request.crossfade
|
||||||
import com.fabledsword.minstrel.cache.CacheIndexer
|
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.mutations.MutationReplayer
|
||||||
import com.fabledsword.minstrel.cache.sync.SyncController
|
import com.fabledsword.minstrel.cache.sync.SyncController
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
@@ -120,6 +121,14 @@ class MinstrelApplication :
|
|||||||
*/
|
*/
|
||||||
@Suppress("unused") @Inject lateinit var cacheIndexer: CacheIndexer
|
@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
|
* Same construct-the-singleton trick — AudioPrefetcher's init
|
||||||
* block subscribes to PlayerController.uiState +
|
* 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
|
* User-tunable audio cache settings. Persisted as a JSON
|
||||||
* blob on the auth_session single-row table via [AuthStore].
|
* blob on the auth_session single-row table via [AuthStore].
|
||||||
*
|
*
|
||||||
* - [likedCapBytes]: budget for cached files of liked tracks. 0 means
|
* - [likedCapBytes]: the Liked songs budget, for the cached audio of
|
||||||
* unlimited. Nothing enforces it yet: SimpleCache evicts by
|
* liked tracks. 0 means unlimited.
|
||||||
* [rollingCapBytes] alone, and the Storage card hides this control.
|
* - [rollingCapBytes]: the Recently played budget, for everything else
|
||||||
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
|
* streamed or prefetched. 0 means unlimited.
|
||||||
* 0 means unlimited. Effective at next app start (SimpleCache is
|
* A track that is liked and also recently played counts against Liked
|
||||||
* built once per process).
|
* only. Both caps apply as soon as they change ([TwoBucketCacheEvictor]).
|
||||||
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
* - [prefetchWindow]: 1..10. Number of next-tracks `AudioPrefetcher`
|
||||||
* pre-downloads.
|
* pre-downloads.
|
||||||
* - [cacheLikedTracks]: when true, liking a track should also pin its
|
* - [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.ArtistRef
|
||||||
import com.fabledsword.minstrel.models.TrackRef
|
import com.fabledsword.minstrel.models.TrackRef
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
@@ -117,6 +121,20 @@ class LikesRepository @Inject constructor(
|
|||||||
fun observeLikedTrackIds(): Flow<Set<String>> =
|
fun observeLikedTrackIds(): Flow<Set<String>> =
|
||||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).map { it.toSet() }
|
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. */
|
/** One-shot snapshot of the liked track-id set — for the offline pool filter. */
|
||||||
suspend fun likedTrackIds(): Set<String> =
|
suspend fun likedTrackIds(): Set<String> =
|
||||||
likeDao.observeLikedIdsOfType(currentUserId(), ENTITY_TRACK).first().toSet()
|
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.DataSourceBitmapLoader
|
||||||
import androidx.media3.datasource.cache.CacheDataSink
|
import androidx.media3.datasource.cache.CacheDataSink
|
||||||
import androidx.media3.datasource.cache.CacheDataSource
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
|
||||||
import androidx.media3.datasource.cache.SimpleCache
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.DefaultRenderersFactory
|
import androidx.media3.exoplayer.DefaultRenderersFactory
|
||||||
@@ -20,7 +19,7 @@ import androidx.media3.exoplayer.audio.DefaultAudioSink
|
|||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
import androidx.media3.session.CacheBitmapLoader
|
import androidx.media3.session.CacheBitmapLoader
|
||||||
import com.fabledsword.minstrel.auth.AuthStore
|
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.GainAudioProcessor
|
||||||
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
import com.fabledsword.minstrel.player.gain.ReplayGainStore
|
||||||
import com.fabledsword.minstrel.player.output.ActiveUpnpHolder
|
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:
|
* `simpleCache` is exposed as a val for what needs the on-disk bytes:
|
||||||
* the cached-track ids, the offline pools, the prefetcher and the
|
* the cached-track ids, the offline pools, the prefetcher and the
|
||||||
* Storage card. The `LeastRecentlyUsedCacheEvictor` bounds it at the
|
* Storage card. [TwoBucketCacheEvictor] bounds it by two budgets, Liked
|
||||||
* rolling cap; there is no separate liked-track bucket yet.
|
* songs and Recently played.
|
||||||
*
|
*
|
||||||
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
* `build()` returns a [MinstrelForwardingPlayer] wrapping the internal
|
||||||
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
|
* ExoPlayer. When the UPnP route drops (the poll loop's consecutive-
|
||||||
@@ -56,7 +55,7 @@ import javax.inject.Singleton
|
|||||||
class PlayerFactory @Inject constructor(
|
class PlayerFactory @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val okHttpClient: OkHttpClient,
|
private val okHttpClient: OkHttpClient,
|
||||||
private val cacheConfig: CacheConfig,
|
private val cacheEvictor: TwoBucketCacheEvictor,
|
||||||
private val activeUpnpHolder: ActiveUpnpHolder,
|
private val activeUpnpHolder: ActiveUpnpHolder,
|
||||||
private val remoteState: RemotePlayerState,
|
private val remoteState: RemotePlayerState,
|
||||||
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
private val serverHealth: com.fabledsword.minstrel.connectivity.NetworkStatusController,
|
||||||
@@ -68,9 +67,9 @@ class PlayerFactory @Inject constructor(
|
|||||||
|
|
||||||
val simpleCache: SimpleCache = SimpleCache(
|
val simpleCache: SimpleCache = SimpleCache(
|
||||||
cacheDir,
|
cacheDir,
|
||||||
LeastRecentlyUsedCacheEvictor(cacheConfig.rollingCapBytes),
|
cacheEvictor,
|
||||||
StandaloneDatabaseProvider(context),
|
StandaloneDatabaseProvider(context),
|
||||||
)
|
).also(cacheEvictor::attach)
|
||||||
|
|
||||||
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
// MutableSharedFlow with extraBufferCapacity=1 + DROP_OLDEST so a burst
|
||||||
// of drop events (rapid SOAP failures during a single tear-down) surfaces
|
// 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