DLNA renderers, app updates told like Librarian, acquisition without Lidarr, Collection and admin rework (M502–M505, M542) #156

Merged
bvandeusen merged 117 commits from dev into main 2026-10-26 12:32:33 -04:00
10 changed files with 468 additions and 79 deletions
Showing only changes of commit 7ec8a82871 - Show all commits
@@ -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 +
@@ -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
}
}
@@ -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)
}
}
}
}
@@ -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
}
}
@@ -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
@@ -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,
)
}
}
@@ -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())
}
}