feat: M3 weighted shuffle v1 — real /api/radio with scoring #21
@@ -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 {
|
||||
|
||||
+37
@@ -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)
|
||||
|
||||
+4
@@ -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?)
|
||||
}
|
||||
|
||||
+7
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user