feat: M3 weighted shuffle v1 — real /api/radio with scoring #21

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 11:00:29 -04:00
6 changed files with 100 additions and 2 deletions
Showing only changes of commit b438772c96 - Show all commits
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.auth
import com.fabledsword.minstrel.cache.audiocache.CacheSettings
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
import com.fabledsword.minstrel.di.ApplicationScope
@@ -8,6 +9,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import javax.inject.Inject
import javax.inject.Singleton
@@ -46,6 +48,11 @@ class AuthStore @Inject constructor(
private val clientIdState = MutableStateFlow<String?>(null)
val clientId: StateFlow<String?> = clientIdState.asStateFlow()
private val cacheSettingsState = MutableStateFlow(CacheSettings.DEFAULT)
val cacheSettings: StateFlow<CacheSettings> = cacheSettingsState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
init {
scope.launch {
dao.observe().collect { row ->
@@ -54,10 +61,18 @@ class AuthStore @Inject constructor(
userJsonState.value = row?.userJson
themeModeState.value = row?.themeMode
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
}
}
}
private fun decodeCacheSettings(raw: String?): CacheSettings {
if (raw.isNullOrEmpty()) return CacheSettings.DEFAULT
return runCatching {
json.decodeFromString(CacheSettings.serializer(), raw)
}.getOrDefault(CacheSettings.DEFAULT)
}
fun setSessionCookie(value: String?) {
sessionCookieState.value = value
scope.launch { persistCookie(value) }
@@ -83,6 +98,12 @@ class AuthStore @Inject constructor(
scope.launch { persistClientId(value) }
}
fun setCacheSettings(value: CacheSettings) {
cacheSettingsState.value = value
val encoded = json.encodeToString(CacheSettings.serializer(), value)
scope.launch { persistCacheSettings(encoded) }
}
private suspend fun persistCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -123,6 +144,14 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistCacheSettings(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(cacheSettingsJson = json))
} else {
dao.setCacheSettingsJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
sessionCookie = sessionCookieState.value,
@@ -130,6 +159,10 @@ class AuthStore @Inject constructor(
userJson = userJsonState.value,
themeMode = themeModeState.value,
clientId = clientIdState.value,
cacheSettingsJson = json.encodeToString(
CacheSettings.serializer(),
cacheSettingsState.value,
),
)
companion object {
@@ -0,0 +1,37 @@
package com.fabledsword.minstrel.cache.audiocache
import kotlinx.serialization.Serializable
private const val FIVE_GIB_BYTES = 5L * 1024 * 1024 * 1024
private const val DEFAULT_PREFETCH_WINDOW = 5
/**
* User-tunable audio cache settings. Mirrors Flutter's `CacheSettings`
* (cache_settings_provider.dart) field-for-field. 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. Effective at next app start (SimpleCache is built once
* per process).
* - [rollingCapBytes]: budget for incidental / auto-prefetch downloads.
* Same 0=unlimited / restart-to-apply caveat.
* - [prefetchWindow]: 1..10. Number of next-tracks the prefetcher
* pre-downloads. Has no effect until the prefetcher lands (separate
* audit follow-up) — persisted now so the user can pre-configure.
* - [cacheLikedTracks]: when true, liking a track should also pin its
* audio to the liked-cache bucket. Has no effect until pin-on-like
* lands (separate follow-up).
*
* Defaults match Flutter: 5 GiB per bucket, prefetch=5, cache-liked=true.
*/
@Serializable
data class CacheSettings(
val likedCapBytes: Long = FIVE_GIB_BYTES,
val rollingCapBytes: Long = FIVE_GIB_BYTES,
val prefetchWindow: Int = DEFAULT_PREFETCH_WINDOW,
val cacheLikedTracks: Boolean = true,
) {
companion object {
val DEFAULT: CacheSettings = CacheSettings()
}
}
@@ -59,7 +59,7 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
CachedHomeIndexEntity::class,
AuthSessionEntity::class,
],
version = 4,
version = 5,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -38,4 +38,8 @@ interface AuthSessionDao {
/** Partial update: change only the stable client install id. */
@Query("UPDATE auth_session SET clientId = :clientId WHERE id = 0")
suspend fun setClientId(clientId: String?)
/** Partial update: change only the serialized cache settings. */
@Query("UPDATE auth_session SET cacheSettingsJson = :json WHERE id = 0")
suspend fun setCacheSettingsJson(json: String?)
}
@@ -29,4 +29,11 @@ data class AuthSessionEntity(
val userJson: String? = null,
val themeMode: String? = null,
val clientId: String? = null,
/**
* JSON-encoded CacheSettings (cache/audiocache/CacheSettings.kt).
* Null = use defaults. Stored as a JSON blob rather than four
* individual columns to keep schema changes cheap when the
* CacheSettings shape evolves.
*/
val cacheSettingsJson: String? = null,
)
@@ -1,5 +1,6 @@
package com.fabledsword.minstrel.player
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.audiocache.CacheConfig
import dagger.Module
import dagger.Provides
@@ -21,7 +22,23 @@ import javax.inject.Singleton
@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(): CacheConfig = CacheConfig()
fun provideCacheConfig(authStore: AuthStore): CacheConfig {
val s = authStore.cacheSettings.value
return CacheConfig(
likedCapBytes = s.likedCapBytes,
rollingCapBytes = s.rollingCapBytes,
)
}
}