feat: per-user volume leveling preference, synced across devices (M464 #4998)
release / govulncheck (push) Successful in 22s
release / go (push) Successful in 1m17s
release / web (push) Failing after 1m5s
release / android (push) Failing after 1m51s
release / Attach APK to the Release (tag releases only) (push) Canceled after 0s
release / Build + push container image (push) Canceled after 0s
release / Verify release artifacts (tag releases only) (push) Canceled after 0s
release / integration (push) Canceled after 4m12s
release / Build signed APK (releases and dev) (push) Canceled after 3m21s

Mode (off, auto, track, album), target (-18, -16, -14 LUFS) and boost
(within headroom, or fully with a limiter), stored per user on the server
so the web player, the Android app and casts apply the same one.

- Server: user_normalization_prefs (migration 0067), GET/PUT
  /api/me/normalization; a whole-body PUT, validated, last write wins.
- Web: Settings > Playback > Volume leveling. Saves at once, restores the
  old choice if the save fails, and caches the value for the player.
- Android: Settings card. The device keeps a copy for offline playback
  (Room v9 with an explicit migration, so the upgrade wipes nothing).
  Writes are offline-first: shown at once, PUT best effort, queued on
  failure (NORMALIZATION_SET, collapsed to the newest). A refresh never
  overwrites a change still queued.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 14:29:40 -04:00
co-authored by Claude Opus 5.5
parent e3aa8629d3
commit af36b2f24a
34 changed files with 1225 additions and 5 deletions
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
import com.fabledsword.minstrel.models.wire.MyProfileWire
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
@@ -59,6 +60,14 @@ interface MeApi {
*/
@PUT("api/me/listenbrainz")
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
/** The caller's loudness-normalization preference, or the defaults if never set. */
@GET("api/me/normalization")
suspend fun getNormalization(): NormalizationPrefs
/** Replaces the whole preference; returns what the server stored. */
@PUT("api/me/normalization")
suspend fun putNormalization(@Body body: NormalizationPrefs): NormalizationPrefs
}
/**
@@ -4,6 +4,7 @@ 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
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.async
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
private val diagnosticsOptOutState = MutableStateFlow(false)
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
private val json = Json { ignoreUnknownKeys = true }
// Serialises every cookie persist with the one-time hydration, so a
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
clientIdState.value = row?.clientId
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
normalizationState.value = decodeNormalization(row?.normalizationJson)
}
}
}
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
}.getOrDefault(CacheSettings.DEFAULT)
}
private fun decodeNormalization(raw: String?): NormalizationPrefs {
if (raw.isNullOrEmpty()) return NormalizationPrefs.DEFAULT
return runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), raw)
}.getOrDefault(NormalizationPrefs.DEFAULT)
}
/**
* Suspends until the stored session cookie has been loaded into
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
scope.launch { persistDiagnosticsOptOut(value) }
}
fun setNormalization(value: NormalizationPrefs) {
normalizationState.value = value
val encoded = json.encodeToString(NormalizationPrefs.serializer(), value)
scope.launch { persistNormalization(encoded) }
}
private suspend fun persistLegacyCookie(value: String?) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(sessionCookie = value))
@@ -243,6 +261,14 @@ class AuthStore @Inject constructor(
}
}
private suspend fun persistNormalization(json: String) {
if (dao.get() == null) {
dao.upsert(currentEntity().copy(normalizationJson = json))
} else {
dao.setNormalizationJson(json)
}
}
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
id = ROW_ID,
// Never copied into the row: the cookie lives in the vault, and
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
cacheSettingsState.value,
),
diagnosticsOptOut = diagnosticsOptOutState.value,
normalizationJson = json.encodeToString(
NormalizationPrefs.serializer(),
normalizationState.value,
),
)
companion object {
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.room.TypeConverters
import androidx.room.migration.Migration
import androidx.sqlite.db.SupportSQLiteDatabase
import com.fabledsword.minstrel.cache.db.dao.AudioCacheIndexDao
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
@@ -65,13 +67,18 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
AuthSessionEntity::class,
DiagnosticEventEntity::class,
],
// v9: + auth_session.normalizationJson, the loudness-normalization
// preference (M464 #4998). The first schema step with an explicit
// Migration (MIGRATION_8_9): a destructive rebuild would also wipe this
// row — the server address and theme — and the queued offline writes,
// which is too much to lose for one added column.
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
// so cache-first surfaces stop offering files that cannot stream.
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
// which is exactly right here: the next sync refills every row with the
// new column populated, so there is nothing to migrate by hand.
version = 8,
version = 9,
exportSchema = true,
)
@TypeConverters(MinstrelTypeConverters::class)
@@ -92,3 +99,10 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun authSessionDao(): AuthSessionDao
abstract fun diagnosticEventDao(): DiagnosticEventDao
}
/** v8 → v9: add the nullable normalization preference column (#4998). */
val MIGRATION_8_9: Migration = object : Migration(8, 9) {
override fun migrate(db: SupportSQLiteDatabase) {
db.execSQL("ALTER TABLE auth_session ADD COLUMN normalizationJson TEXT")
}
}
@@ -37,6 +37,7 @@ object DatabaseModule {
// launch, so users lose only the unsynced mutation queue
// (acceptable while we're iterating). Replace with explicit
// Migration entries before the first tagged release.
.addMigrations(MIGRATION_8_9)
.fallbackToDestructiveMigration(dropAllTables = true)
.build()
@@ -46,4 +46,8 @@ interface AuthSessionDao {
/** Partial update: change only the per-device diagnostics opt-out. */
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
suspend fun setDiagnosticsOptOut(optOut: Boolean)
/** Partial update: change only the serialized normalization preference. */
@Query("UPDATE auth_session SET normalizationJson = :json WHERE id = 0")
suspend fun setNormalizationJson(json: String?)
}
@@ -31,4 +31,8 @@ interface CachedMutationDao {
@Query("DELETE FROM cached_mutations")
suspend fun clear()
/** Whether a write of [kind] is still waiting to be replayed. */
@Query("SELECT EXISTS(SELECT 1 FROM cached_mutations WHERE kind = :kind)")
suspend fun hasPending(kind: String): Boolean
}
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
* choice lives here. Default false = honor the account flag.
*/
val diagnosticsOptOut: Boolean = false,
/**
* JSON-encoded NormalizationPrefs (settings/data), the last value seen
* from the server or set here (M464 #4998). Null = never fetched; the
* defaults apply. Kept so offline playback still levels.
*/
val normalizationJson: String? = null,
)
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
@@ -41,6 +42,11 @@ object MutationKind {
// an undo collapses to the latest intent instead of replaying as two
// opposed calls whose order decides the outcome.
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
// M464 #4998 loudness-normalization preference. The payload is the whole
// preference, a target state like the toggles above, so queued changes
// collapse to the last one and an older one can never be replayed last.
const val NORMALIZATION_SET: String = "normalization_set"
}
/**
@@ -177,6 +183,12 @@ class MutationQueue @Inject constructor(
),
)
/** Queues the user's whole normalization preference for replay. */
suspend fun enqueueNormalizationSet(prefs: NormalizationPrefs): Long = insertUserDriven(
MutationKind.NORMALIZATION_SET,
json.encodeToString(NormalizationPrefs.serializer(), prefs),
)
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
MutationKind.REQUEST_CANCEL,
json.encodeToString(
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
import com.fabledsword.minstrel.api.endpoints.EventsApi
import com.fabledsword.minstrel.api.endpoints.FlagRequest
import com.fabledsword.minstrel.api.endpoints.LikesApi
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorReportRequest
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
@@ -22,6 +23,7 @@ import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.di.ApplicationScope
import com.fabledsword.minstrel.likes.data.LikesRepository
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.models.wire.CreateRequestBody
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.distinctUntilChanged
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
private val eventsApi: EventsApi = retrofit.create()
private val requestsApi: RequestsApi = retrofit.create()
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
private val meApi: MeApi = retrofit.create()
private val mutex = Mutex()
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(row.payload)
MutationKind.NORMALIZATION_SET -> dispatchNormalizationSet(row.payload)
// Unknown kind — drop so a stale schema entry can't wedge the queue.
else -> Outcome.DROP
}
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
return Outcome.SENT
}
/**
* Sends the queued normalization preference. The device already shows
* it, so the server's echo is not written back: a change made since the
* row was queued would be a newer row, and the collapse keeps only that.
*/
private suspend fun dispatchNormalizationSet(payload: String): Outcome {
meApi.putNormalization(json.decodeFromString(NormalizationPrefs.serializer(), payload))
return Outcome.SENT
}
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
playbackErrorsApi.report(
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
/**
* Row ids of desired-state toggles superseded by a later toggle for the same
* entity. Applies to every kind whose payload encodes a TARGET state rather
* than an action — like-toggles and suggestion snoozes (#2374) — because
* than an action — like-toggles, suggestion snoozes (#2374) and the
* normalization preference (#4998) — because
* replaying a stale one last would invert the final state.
*
* Top-level and pure so it can be unit-tested without standing up a Retrofit
@@ -340,5 +355,10 @@ private fun toggleKeyOf(row: CachedMutationEntity, json: Json): String? = when (
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
// One preference per user, so every normalization row shares one key.
MutationKind.NORMALIZATION_SET -> runCatching {
json.decodeFromString(NormalizationPrefs.serializer(), row.payload)
}.getOrNull()?.let { row.kind }
else -> null
}
@@ -0,0 +1,55 @@
package com.fabledsword.minstrel.settings.data
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* The user's loudness-normalization preference (M464 #4998). The server
* holds it, so this device, the web player and casts all level the same
* way; a copy is kept on the device so offline playback still applies it.
* Field names and values match `GET/PUT /api/me/normalization`.
*
* No constructor defaults, on purpose: the app's Json leaves out any field
* equal to its default (encodeDefaults is off), and the server refuses a PUT
* missing a field. Without defaults every field is always sent.
*/
@Serializable
data class NormalizationPrefs(
val mode: NormalizationMode,
@SerialName("target_lufs") val targetLufs: Int,
val boost: NormalizationBoost,
) {
companion object {
/** The targets the server accepts, quietest first. */
val TARGETS: List<Int> = listOf(-18, -16, -14)
/** Must match library.DefaultNormalizationPrefs on the server. */
val DEFAULT: NormalizationPrefs = NormalizationPrefs(
mode = NormalizationMode.AUTO,
targetLufs = -18,
boost = NormalizationBoost.HEADROOM,
)
}
}
@Serializable
enum class NormalizationMode {
/** Tracks play at their mastered volume. */
@SerialName("off") OFF,
/** Album gain while an album plays in order, track gain otherwise. */
@SerialName("auto") AUTO,
@SerialName("track") TRACK,
@SerialName("album") ALBUM,
}
@Serializable
enum class NormalizationBoost {
/** Quiet tracks are raised only as far as their true peak allows. */
@SerialName("headroom") HEADROOM,
/** Quiet tracks are raised all the way and a limiter holds the peaks. */
@SerialName("limiter") LIMITER,
}
@@ -0,0 +1,67 @@
package com.fabledsword.minstrel.settings.data
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import retrofit2.Retrofit
import retrofit2.create
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton
/**
* The user's loudness-normalization preference (M464 #4998). The device's
* copy lives in [AuthStore] so playback reads it offline; the server's copy
* is what other devices and casts use.
*
* Writes follow rule 100: the device shows the change at once, the PUT is
* best effort, and a failed PUT is queued for the MutationReplayer.
*/
@Singleton
class NormalizationRepository @Inject constructor(
retrofit: Retrofit,
private val authStore: AuthStore,
private val mutationQueue: MutationQueue,
private val mutationDao: CachedMutationDao,
) {
private val api: MeApi = retrofit.create()
val prefs: StateFlow<NormalizationPrefs> = authStore.normalization
/**
* Takes the server's value, unless a change made here is still queued:
* the server has not seen it yet, and taking its older value would undo
* the change on screen until the replay landed. Throws on a network
* failure; the device's copy then stands.
*/
suspend fun refresh() {
val server = api.getNormalization()
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) return
authStore.setNormalization(server)
}
suspend fun set(next: NormalizationPrefs) {
authStore.setNormalization(next)
// An earlier change still queued would be replayed after this PUT
// and undo it. Queue behind it instead: the replayer sends only the
// newest queued preference.
if (mutationDao.hasPending(MutationKind.NORMALIZATION_SET)) {
mutationQueue.enqueueNormalizationSet(next)
return
}
try {
api.putNormalization(next)
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
Timber.i(e, "normalization: PUT failed; queued for replay")
mutationQueue.enqueueNormalizationSet(next)
}
}
}
@@ -0,0 +1,143 @@
package com.fabledsword.minstrel.settings.ui
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.SegmentedButton
import androidx.compose.material3.SegmentedButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.fabledsword.minstrel.settings.data.NormalizationBoost
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
/**
* Volume leveling (M464 #4998). The choice is stored on the server, so the
* web player and casts follow it too.
*/
@Composable
fun NormalizationCard(viewModel: NormalizationViewModel = hiltViewModel()) {
val prefs by viewModel.prefs.collectAsStateWithLifecycle()
NormalizationCardContent(prefs = prefs, onChange = viewModel::update)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun NormalizationCardContent(
prefs: NormalizationPrefs,
onChange: (NormalizationPrefs) -> Unit,
) {
ElevatedCard(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = "Volume leveling",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onSurface,
)
Hint(modeHint(prefs.mode))
val modes = NormalizationMode.entries
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
modes.forEachIndexed { index, mode ->
SegmentedButton(
selected = mode == prefs.mode,
onClick = { onChange(prefs.copy(mode = mode)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = modes.size,
),
) { Text(modeLabel(mode)) }
}
}
if (prefs.mode != NormalizationMode.OFF) {
TargetRow(prefs = prefs, onChange = onChange)
BoostRow(prefs = prefs, onChange = onChange)
}
}
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun TargetRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Text(
text = "Target loudness",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
val targets = NormalizationPrefs.TARGETS
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
targets.forEachIndexed { index, lufs ->
SegmentedButton(
selected = lufs == prefs.targetLufs,
onClick = { onChange(prefs.copy(targetLufs = lufs)) },
shape = SegmentedButtonDefaults.itemShape(
index = index,
count = targets.size,
),
) { Text("$lufs LUFS") }
}
}
}
@Composable
private fun BoostRow(prefs: NormalizationPrefs, onChange: (NormalizationPrefs) -> Unit) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Boost quiet tracks fully",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurface,
)
Hint("A limiter catches the peaks.")
}
Spacer(Modifier.size(12.dp))
Switch(
checked = prefs.boost == NormalizationBoost.LIMITER,
onCheckedChange = { on ->
val boost = if (on) NormalizationBoost.LIMITER else NormalizationBoost.HEADROOM
onChange(prefs.copy(boost = boost))
},
)
}
}
@Composable
private fun Hint(text: String) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
private fun modeLabel(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Off"
NormalizationMode.AUTO -> "Auto"
NormalizationMode.TRACK -> "Track"
NormalizationMode.ALBUM -> "Album"
}
private fun modeHint(mode: NormalizationMode): String = when (mode) {
NormalizationMode.OFF -> "Tracks play at their mastered volume."
NormalizationMode.AUTO -> "Album gain for whole albums, track gain otherwise."
NormalizationMode.TRACK -> "Every track at the same loudness."
NormalizationMode.ALBUM -> "Albums keep their own quiet and loud tracks."
}
@@ -0,0 +1,44 @@
package com.fabledsword.minstrel.settings.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import com.fabledsword.minstrel.settings.data.NormalizationRepository
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
/**
* Backs the Volume leveling card. Shows the device's copy straight away and
* refreshes it from the server on open, so a change made on the web shows
* here too.
*/
@HiltViewModel
class NormalizationViewModel @Inject constructor(
private val repository: NormalizationRepository,
) : ViewModel() {
val prefs: StateFlow<NormalizationPrefs> = repository.prefs
init {
viewModelScope.launch {
try {
repository.refresh()
} catch (e: CancellationException) {
throw e
} catch (
@Suppress("TooGenericExceptionCaught") e: Throwable,
) {
// Offline: the device's copy stands.
Timber.i(e, "normalization: refresh failed")
}
}
}
fun update(next: NormalizationPrefs) {
viewModelScope.launch { repository.set(next) }
}
}
@@ -161,6 +161,7 @@ private fun SettingsList(
onToggle = onToggleDiagnostics,
)
}
NormalizationCard()
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
StorageCard()
AboutCard()
@@ -1,6 +1,8 @@
package com.fabledsword.minstrel.cache.mutations
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
import com.fabledsword.minstrel.settings.data.NormalizationMode
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
fun `an empty queue collapses nothing`() {
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
}
private fun normalizationRow(id: Long, mode: NormalizationMode) = CachedMutationEntity(
id = id,
kind = MutationKind.NORMALIZATION_SET,
payload = json.encodeToString(
NormalizationPrefs.serializer(),
NormalizationPrefs.DEFAULT.copy(mode = mode),
),
)
// There is one preference per user, so any two queued changes to it
// collapse, and only the newest is sent (#4998).
@Test
fun `only the newest queued normalization change survives`() {
val rows = listOf(
normalizationRow(1, NormalizationMode.TRACK),
snoozeRow(2, "mb-a", desiredSnoozed = true),
normalizationRow(3, NormalizationMode.OFF),
normalizationRow(4, NormalizationMode.ALBUM),
)
assertEquals(setOf(1L, 3L), supersededToggleIds(rows, json))
}
}
@@ -0,0 +1,90 @@
package com.fabledsword.minstrel.settings.data
import com.fabledsword.minstrel.api.endpoints.MeApi
import com.fabledsword.minstrel.auth.AuthStore
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
import com.fabledsword.minstrel.cache.mutations.MutationKind
import com.fabledsword.minstrel.cache.mutations.MutationQueue
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Test
import retrofit2.Retrofit
import java.io.IOException
/**
* The normalization preference is written offline-first (rule 100): the
* device shows the change at once, and a change the server has not seen
* yet is never overwritten or reordered.
*/
class NormalizationRepositoryTest {
private val api: MeApi = mockk()
private val authStore: AuthStore = mockk(relaxed = true) {
every { normalization } returns MutableStateFlow(NormalizationPrefs.DEFAULT)
}
private val queue: MutationQueue = mockk(relaxed = true)
private val dao: CachedMutationDao = mockk()
private val retrofit: Retrofit = mockk {
every { create(MeApi::class.java) } returns api
}
private val repo = NormalizationRepository(retrofit, authStore, queue, dao)
private val album = NormalizationPrefs.DEFAULT.copy(mode = NormalizationMode.ALBUM)
@Test
fun `a change is shown at once and sent`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
coEvery { api.putNormalization(album) } returns album
repo.set(album)
verify { authStore.setNormalization(album) }
coVerify { api.putNormalization(album) }
coVerify(exactly = 0) { queue.enqueueNormalizationSet(any()) }
}
@Test
fun `a change that cannot reach the server is queued`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
coEvery { api.putNormalization(album) } throws IOException("offline")
repo.set(album)
verify { authStore.setNormalization(album) }
coVerify { queue.enqueueNormalizationSet(album) }
}
@Test
fun `a change behind a queued one queues too, so the older one cannot land last`() = runTest {
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
repo.set(album)
coVerify(exactly = 0) { api.putNormalization(any()) }
coVerify { queue.enqueueNormalizationSet(album) }
}
@Test
fun `refresh takes the server value`() = runTest {
coEvery { api.getNormalization() } returns album
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns false
repo.refresh()
verify { authStore.setNormalization(album) }
}
@Test
fun `refresh leaves a queued change on screen`() = runTest {
coEvery { api.getNormalization() } returns NormalizationPrefs.DEFAULT
coEvery { dao.hasPending(MutationKind.NORMALIZATION_SET) } returns true
repo.refresh()
verify(exactly = 0) { authStore.setNormalization(any()) }
}
}
+2
View File
@@ -109,6 +109,8 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev
authed.Put("/me/password", h.handleChangePassword)
authed.Put("/me/profile", h.handleUpdateMyProfile)
authed.Put("/me/timezone", h.handlePutTimezone)
authed.Get("/me/normalization", h.handleGetMyNormalization)
authed.Put("/me/normalization", h.handlePutMyNormalization)
authed.Post("/me/api-token", h.handleRegenerateMyAPIToken)
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
+51
View File
@@ -0,0 +1,51 @@
package api
import (
"encoding/json"
"errors"
"net/http"
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
// GET /api/me/normalization returns the caller's loudness-normalization
// preference, or the defaults when they have never set one (M464 #4998).
func (h *handlers) handleGetMyNormalization(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
return
}
p, err := library.LoadNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID)
if err != nil {
writeErrWithLog(w, h.logger, "normalization: load", apierror.InternalMsg("lookup failed", err))
return
}
writeJSON(w, http.StatusOK, p)
}
// PUT /api/me/normalization replaces the caller's preference with the whole
// body. Every field is required, so a client replaying a queued change
// offline sends the full state it showed the user, and the last write wins.
func (h *handlers) handlePutMyNormalization(w http.ResponseWriter, r *http.Request) {
caller, ok := requireUser(w, r)
if !ok {
return
}
var body library.NormalizationPrefs
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeErr(w, apierror.BadRequest("bad_body", "invalid JSON body"))
return
}
p, err := library.SaveNormalizationPrefs(r.Context(), dbq.New(h.pool), caller.ID, body)
if errors.Is(err, library.ErrNormalizationPrefsInvalid) {
writeErr(w, apierror.BadRequest("invalid_normalization", err.Error()))
return
}
if err != nil {
writeErrWithLog(w, h.logger, "normalization: save", apierror.InternalMsg("save failed", err))
return
}
writeJSON(w, http.StatusOK, p)
}
+61
View File
@@ -0,0 +1,61 @@
package api
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/go-chi/chi/v5"
"git.fabledsword.com/bvandeusen/minstrel/internal/library"
)
func TestMyNormalization_GetPut(t *testing.T) {
h, pool := testHandlers(t)
user := seedUser(t, pool, "norm1", "pw", false)
r := chi.NewRouter()
r.Get("/api/me/normalization", h.handleGetMyNormalization)
r.Put("/api/me/normalization", h.handlePutMyNormalization)
do := func(method, body string) (int, library.NormalizationPrefs) {
t.Helper()
req := withUser(httptest.NewRequest(method, "/api/me/normalization", bytes.NewBufferString(body)), user)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
var p library.NormalizationPrefs
if rec.Code == http.StatusOK {
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil {
t.Fatalf("decode %s: %v", rec.Body.String(), err)
}
}
return rec.Code, p
}
if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != library.DefaultNormalizationPrefs {
t.Fatalf("GET before any save = %d %+v, want 200 with the defaults", code, p)
}
want := library.NormalizationPrefs{Mode: "album", TargetLUFS: -16, Boost: "limiter"}
if code, p := do(http.MethodPut, `{"mode":"album","target_lufs":-16,"boost":"limiter"}`); code != http.StatusOK || p != want {
t.Fatalf("PUT = %d %+v, want 200 %+v", code, p, want)
}
if code, p := do(http.MethodGet, ""); code != http.StatusOK || p != want {
t.Errorf("GET after PUT = %d %+v, want %+v", code, p, want)
}
// Bad values and partial bodies are refused, and the stored prefs stand.
for _, body := range []string{
`{"mode":"loud","target_lufs":-16,"boost":"limiter"}`,
`{"mode":"album","target_lufs":-23,"boost":"limiter"}`,
`{"mode":"album","target_lufs":-16}`,
`not json`,
} {
if code, _ := do(http.MethodPut, body); code != http.StatusBadRequest {
t.Errorf("PUT %s = %d, want 400", body, code)
}
}
if _, p := do(http.MethodGet, ""); p != want {
t.Errorf("a refused PUT changed the prefs to %+v", p)
}
}
+8
View File
@@ -784,6 +784,14 @@ type UserInvite struct {
RedeemedBy pgtype.UUID
}
type UserNormalizationPref struct {
UserID pgtype.UUID
Mode string
TargetLufs int16
Boost string
UpdatedAt pgtype.Timestamptz
}
type YouMightLikeAlbum struct {
UserID pgtype.UUID
AlbumID pgtype.UUID
@@ -0,0 +1,81 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: normalization_prefs.sql
package dbq
import (
"context"
"github.com/jackc/pgx/v5/pgtype"
)
const getNormalizationPrefs = `-- name: GetNormalizationPrefs :one
SELECT mode, target_lufs, boost, updated_at
FROM user_normalization_prefs
WHERE user_id = $1
`
type GetNormalizationPrefsRow struct {
Mode string
TargetLufs int16
Boost string
UpdatedAt pgtype.Timestamptz
}
func (q *Queries) GetNormalizationPrefs(ctx context.Context, userID pgtype.UUID) (GetNormalizationPrefsRow, error) {
row := q.db.QueryRow(ctx, getNormalizationPrefs, userID)
var i GetNormalizationPrefsRow
err := row.Scan(
&i.Mode,
&i.TargetLufs,
&i.Boost,
&i.UpdatedAt,
)
return i, err
}
const upsertNormalizationPrefs = `-- name: UpsertNormalizationPrefs :one
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
VALUES ($1, $2, $3, $4)
ON CONFLICT (user_id) DO UPDATE SET
mode = EXCLUDED.mode,
target_lufs = EXCLUDED.target_lufs,
boost = EXCLUDED.boost,
updated_at = now()
RETURNING mode, target_lufs, boost, updated_at
`
type UpsertNormalizationPrefsParams struct {
UserID pgtype.UUID
Mode string
TargetLufs int16
Boost string
}
type UpsertNormalizationPrefsRow struct {
Mode string
TargetLufs int16
Boost string
UpdatedAt pgtype.Timestamptz
}
// Whole-row write from either client; the CHECKs in migration 0067 back the
// handler's own validation.
func (q *Queries) UpsertNormalizationPrefs(ctx context.Context, arg UpsertNormalizationPrefsParams) (UpsertNormalizationPrefsRow, error) {
row := q.db.QueryRow(ctx, upsertNormalizationPrefs,
arg.UserID,
arg.Mode,
arg.TargetLufs,
arg.Boost,
)
var i UpsertNormalizationPrefsRow
err := row.Scan(
&i.Mode,
&i.TargetLufs,
&i.Boost,
&i.UpdatedAt,
)
return i, err
}
@@ -0,0 +1 @@
DROP TABLE IF EXISTS user_normalization_prefs;
@@ -0,0 +1,22 @@
-- Each user's loudness-normalization preference (M464 #4998). It is kept on
-- the server, not per device, so the web player, the Android app and the
-- leveled stream for UPnP/Sonos all apply the same one.
--
-- mode: off, auto (album gain while an album plays in order, otherwise
-- track gain), track or album.
-- target: the loudness tracks are leveled to, in LUFS.
-- boost: headroom raises quiet tracks only as far as their true peak
-- allows; limiter raises them all the way and limits the peaks.
--
-- No row means the defaults, which are the column defaults here and
-- library.DefaultNormalizationPrefs in Go (a test holds them equal).
CREATE TABLE user_normalization_prefs (
user_id uuid PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
mode text NOT NULL DEFAULT 'auto'
CHECK (mode IN ('off', 'auto', 'track', 'album')),
target_lufs smallint NOT NULL DEFAULT -18
CHECK (target_lufs IN (-18, -16, -14)),
boost text NOT NULL DEFAULT 'headroom'
CHECK (boost IN ('headroom', 'limiter')),
updated_at timestamptz NOT NULL DEFAULT now()
);
@@ -0,0 +1,16 @@
-- name: GetNormalizationPrefs :one
SELECT mode, target_lufs, boost, updated_at
FROM user_normalization_prefs
WHERE user_id = $1;
-- name: UpsertNormalizationPrefs :one
-- Whole-row write from either client; the CHECKs in migration 0067 back the
-- handler's own validation.
INSERT INTO user_normalization_prefs (user_id, mode, target_lufs, boost)
VALUES (sqlc.arg(user_id), sqlc.arg(mode), sqlc.arg(target_lufs), sqlc.arg(boost))
ON CONFLICT (user_id) DO UPDATE SET
mode = EXCLUDED.mode,
target_lufs = EXCLUDED.target_lufs,
boost = EXCLUDED.boost,
updated_at = now()
RETURNING mode, target_lufs, boost, updated_at;
+4 -3
View File
@@ -90,9 +90,10 @@ var dataTables = []string{
"duplicate_group_members", // M400
"duplicate_groups",
"duplicate_sweeps",
"track_fingerprints", // M400
"track_loudness", // M464
"album_loudness", // M464
"track_fingerprints", // M400
"track_loudness", // M464
"album_loudness", // M464
"user_normalization_prefs", // M464
"tracks",
"albums",
"artists",
+84
View File
@@ -0,0 +1,84 @@
package library
import (
"context"
"errors"
"fmt"
"slices"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// NormalizationPrefs is one user's loudness-normalization preference (M464
// #4998). It lives on the server so every player applies the same one: the
// web player and the Android app read it, and the leveled stream for
// UPnP/Sonos (#5001) renders with it.
type NormalizationPrefs struct {
// Mode is off, auto, track or album. Auto uses album gain while an album
// plays in order and track gain otherwise.
Mode string `json:"mode"`
// TargetLUFS is the loudness tracks are leveled to.
TargetLUFS int16 `json:"target_lufs"`
// Boost is how a track quieter than the target is raised: headroom stops
// at its true peak, limiter goes all the way and limits the peaks.
Boost string `json:"boost"`
}
// The accepted values. Migration 0067's CHECKs hold the same lists.
var (
NormalizationModes = []string{"off", "auto", "track", "album"}
NormalizationTargets = []int16{-18, -16, -14}
NormalizationBoosts = []string{"headroom", "limiter"}
)
// DefaultNormalizationPrefs applies to a user who has never chosen. It must
// equal migration 0067's column defaults; a test holds them together.
var DefaultNormalizationPrefs = NormalizationPrefs{Mode: "auto", TargetLUFS: -18, Boost: "headroom"}
// ErrNormalizationPrefsInvalid is returned for a value outside the lists.
var ErrNormalizationPrefsInvalid = errors.New("normalization preference out of range")
// Validate reports the first field holding a value the server does not
// accept.
func (p NormalizationPrefs) Validate() error {
switch {
case !slices.Contains(NormalizationModes, p.Mode):
return fmt.Errorf("%w: mode %q", ErrNormalizationPrefsInvalid, p.Mode)
case !slices.Contains(NormalizationTargets, p.TargetLUFS):
return fmt.Errorf("%w: target_lufs %d", ErrNormalizationPrefsInvalid, p.TargetLUFS)
case !slices.Contains(NormalizationBoosts, p.Boost):
return fmt.Errorf("%w: boost %q", ErrNormalizationPrefsInvalid, p.Boost)
}
return nil
}
// LoadNormalizationPrefs returns the user's preference, or the defaults if
// they have never set one.
func LoadNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID) (NormalizationPrefs, error) {
row, err := q.GetNormalizationPrefs(ctx, userID)
if errors.Is(err, pgx.ErrNoRows) {
return DefaultNormalizationPrefs, nil
}
if err != nil {
return DefaultNormalizationPrefs, err
}
return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
}
// SaveNormalizationPrefs validates and stores the user's preference,
// returning what was stored.
func SaveNormalizationPrefs(ctx context.Context, q *dbq.Queries, userID pgtype.UUID, p NormalizationPrefs) (NormalizationPrefs, error) {
if err := p.Validate(); err != nil {
return p, err
}
row, err := q.UpsertNormalizationPrefs(ctx, dbq.UpsertNormalizationPrefsParams{
UserID: userID, Mode: p.Mode, TargetLufs: p.TargetLUFS, Boost: p.Boost,
})
if err != nil {
return p, err
}
return NormalizationPrefs{Mode: row.Mode, TargetLUFS: row.TargetLufs, Boost: row.Boost}, nil
}
@@ -0,0 +1,69 @@
package library
import (
"context"
"errors"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
)
func TestNormalizationPrefs_Validate(t *testing.T) {
if err := DefaultNormalizationPrefs.Validate(); err != nil {
t.Fatalf("the defaults are invalid: %v", err)
}
for _, p := range []NormalizationPrefs{
{Mode: "loud", TargetLUFS: -18, Boost: "headroom"},
{Mode: "auto", TargetLUFS: -23, Boost: "headroom"},
{Mode: "auto", TargetLUFS: -18, Boost: "max"},
// A field left out of a PUT body decodes as its zero value.
{Mode: "auto", Boost: "headroom"},
} {
if err := p.Validate(); !errors.Is(err, ErrNormalizationPrefsInvalid) {
t.Errorf("%+v: err = %v, want ErrNormalizationPrefsInvalid", p, err)
}
}
}
// A user with no row gets the Go defaults, and a stored row reads back the
// migration's defaults for any column the insert left out; the two must
// agree, or a user would hear a different level after their first save.
func TestNormalizationPrefs_DefaultsAndRoundTrip_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
q := dbq.New(pool)
user, err := q.CreateUser(ctx, dbq.CreateUserParams{
Username: dbtest.TestUserPrefix + "norm-prefs", PasswordHash: "x", ApiTokenHash: "norm-prefs-token",
})
if err != nil {
t.Fatalf("user: %v", err)
}
got, err := LoadNormalizationPrefs(ctx, q, user.ID)
if err != nil || got != DefaultNormalizationPrefs {
t.Fatalf("unset prefs = %+v, %v; want the defaults", got, err)
}
if _, err := pool.Exec(ctx, "INSERT INTO user_normalization_prefs (user_id) VALUES ($1)", user.ID); err != nil {
t.Fatalf("insert defaults row: %v", err)
}
got, err = LoadNormalizationPrefs(ctx, q, user.ID)
if err != nil || got != DefaultNormalizationPrefs {
t.Errorf("migration defaults = %+v, Go defaults = %+v (err %v)", got, DefaultNormalizationPrefs, err)
}
want := NormalizationPrefs{Mode: "track", TargetLUFS: -14, Boost: "limiter"}
if saved, err := SaveNormalizationPrefs(ctx, q, user.ID, want); err != nil || saved != want {
t.Fatalf("save = %+v, %v; want %+v", saved, err, want)
}
if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
t.Errorf("after save = %+v, want %+v", got, want)
}
if _, err := SaveNormalizationPrefs(ctx, q, user.ID, NormalizationPrefs{Mode: "x"}); !errors.Is(err, ErrNormalizationPrefsInvalid) {
t.Errorf("invalid save: err = %v", err)
}
if got, _ := LoadNormalizationPrefs(ctx, q, user.ID); got != want {
t.Errorf("an invalid save changed the stored prefs to %+v", got)
}
}
+42
View File
@@ -0,0 +1,42 @@
import { api } from './client';
// Loudness normalization preference (M464 #4998). One per user, stored on
// the server so the web player, the Android app and casts agree.
export type NormalizationMode = 'off' | 'auto' | 'track' | 'album';
export type NormalizationTarget = -18 | -16 | -14;
export type NormalizationBoost = 'headroom' | 'limiter';
export type NormalizationPrefs = {
mode: NormalizationMode;
target_lufs: NormalizationTarget;
boost: NormalizationBoost;
};
// Must match library.DefaultNormalizationPrefs on the server.
export const DEFAULT_NORMALIZATION: NormalizationPrefs = {
mode: 'auto',
target_lufs: -18,
boost: 'headroom'
};
export const NORMALIZATION_MODES: NormalizationMode[] = ['off', 'auto', 'track', 'album'];
export const NORMALIZATION_TARGETS: NormalizationTarget[] = [-18, -16, -14];
export function isNormalizationPrefs(v: unknown): v is NormalizationPrefs {
if (typeof v !== 'object' || v === null) return false;
const p = v as Record<string, unknown>;
return (
NORMALIZATION_MODES.includes(p.mode as NormalizationMode) &&
NORMALIZATION_TARGETS.includes(p.target_lufs as NormalizationTarget) &&
(p.boost === 'headroom' || p.boost === 'limiter')
);
}
export function getNormalization(): Promise<NormalizationPrefs> {
return api.get<NormalizationPrefs>('/api/me/normalization');
}
// The whole preference is sent every time; the server keeps the last write.
export function putNormalization(p: NormalizationPrefs): Promise<NormalizationPrefs> {
return api.put<NormalizationPrefs>('/api/me/normalization', p);
}
@@ -0,0 +1,104 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
NORMALIZATION_MODES,
NORMALIZATION_TARGETS,
type NormalizationPrefs
} from '$lib/api/normalization';
import {
normalization,
loadNormalization,
setNormalization
} from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
// Volume leveling (M464 #4998). The choice is the user's, stored on the
// server, so it follows them to the Android app and to casts.
onMount(() => {
void loadNormalization();
});
const modeLabels: Record<string, string> = {
off: 'Off',
auto: 'Auto',
track: 'Track',
album: 'Album'
};
const modeHints: Record<string, string> = {
off: 'Tracks play at their mastered volume.',
auto: 'Album gain for whole albums in order, track gain otherwise.',
track: 'Every track at the same loudness.',
album: 'Albums keep their own quiet and loud tracks.'
};
async function change(patch: Partial<NormalizationPrefs>): Promise<void> {
try {
await setNormalization({ ...normalization.value, ...patch });
} catch {
pushToast('Could not save volume leveling.', 'error');
}
}
const pill = (on: boolean) =>
`flex cursor-pointer items-center rounded border border-border px-3 py-1.5 text-sm ${
on ? 'bg-action-secondary text-action-fg' : 'text-text-primary hover:bg-surface-hover'
}`;
</script>
<div class="space-y-3">
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Volume leveling</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_MODES as m (m)}
<label class={pill(normalization.value.mode === m)}>
<input
type="radio"
name="normalization-mode"
value={m}
checked={normalization.value.mode === m}
onchange={() => change({ mode: m })}
class="sr-only"
/>
{modeLabels[m]}
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">{modeHints[normalization.value.mode]}</p>
</fieldset>
{#if normalization.value.mode !== 'off'}
<fieldset class="space-y-2">
<legend class="block text-sm text-text-secondary">Target loudness</legend>
<div class="flex flex-wrap gap-2">
{#each NORMALIZATION_TARGETS as t (t)}
<label class={pill(normalization.value.target_lufs === t)}>
<input
type="radio"
name="normalization-target"
value={t}
checked={normalization.value.target_lufs === t}
onchange={() => change({ target_lufs: t })}
class="sr-only"
/>
<span class="tabular-nums">{t} LUFS</span>
</label>
{/each}
</div>
<p class="text-xs text-text-secondary">Higher is louder; −18 leaves the most headroom.</p>
</fieldset>
<label class="flex cursor-pointer items-center justify-between gap-3">
<span>
<span class="block text-sm text-text-primary">Boost quiet tracks fully</span>
<span class="block text-xs text-text-secondary">A limiter catches the peaks.</span>
</span>
<input
type="checkbox"
checked={normalization.value.boost === 'limiter'}
onchange={(e) =>
change({ boost: (e.currentTarget as HTMLInputElement).checked ? 'limiter' : 'headroom' })}
class="h-4 w-4 accent-accent"
/>
</label>
{/if}
</div>
@@ -0,0 +1,65 @@
import { afterEach, describe, expect, test, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
vi.mock('$lib/api/normalization', async (importOriginal) => ({
...(await importOriginal<typeof import('$lib/api/normalization')>()),
getNormalization: vi.fn(),
putNormalization: vi.fn()
}));
vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: vi.fn() }));
import NormalizationSetting from './NormalizationSetting.svelte';
import { getNormalization, putNormalization } from '$lib/api/normalization';
import { normalization } from '$lib/stores/normalization.svelte';
import { pushToast } from '$lib/stores/toast.svelte';
afterEach(() => {
vi.clearAllMocks();
localStorage.clear();
});
describe('NormalizationSetting', () => {
test('shows the server value and saves the whole preference on a change', async () => {
vi.mocked(getNormalization).mockResolvedValue({ mode: 'track', target_lufs: -16, boost: 'headroom' });
vi.mocked(putNormalization).mockImplementation(async (p) => p);
render(NormalizationSetting);
await waitFor(() => expect(screen.getByRole('radio', { name: 'Track' })).toBeChecked());
expect(screen.getByRole('radio', { name: '-16 LUFS' })).toBeChecked();
await fireEvent.click(screen.getByRole('radio', { name: 'Album' }));
await waitFor(() =>
expect(putNormalization).toHaveBeenCalledWith({ mode: 'album', target_lufs: -16, boost: 'headroom' })
);
await fireEvent.click(screen.getByRole('checkbox', { name: /boost quiet tracks/i }));
await waitFor(() =>
expect(putNormalization).toHaveBeenLastCalledWith({ mode: 'album', target_lufs: -16, boost: 'limiter' })
);
// The last value seen is cached for the player's first track of a session.
expect(JSON.parse(localStorage.getItem('minstrel:normalization') ?? 'null')).toEqual({
mode: 'album',
target_lufs: -16,
boost: 'limiter'
});
});
test('a failed save puts the previous choice back and says so', async () => {
vi.mocked(getNormalization).mockResolvedValue({ mode: 'auto', target_lufs: -18, boost: 'headroom' });
vi.mocked(putNormalization).mockRejectedValue(new Error('offline'));
render(NormalizationSetting);
await waitFor(() => expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked());
await fireEvent.click(screen.getByRole('radio', { name: 'Off' }));
await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Could not save volume leveling.', 'error'));
expect(normalization.value.mode).toBe('auto');
expect(screen.getByRole('radio', { name: 'Auto' })).toBeChecked();
});
test('off hides the target and boost controls', async () => {
vi.mocked(getNormalization).mockResolvedValue({ mode: 'off', target_lufs: -18, boost: 'headroom' });
render(NormalizationSetting);
await waitFor(() => expect(screen.getByRole('radio', { name: 'Off' })).toBeChecked());
expect(screen.queryByRole('radio', { name: '-18 LUFS' })).toBeNull();
expect(screen.queryByRole('checkbox')).toBeNull();
});
});
@@ -0,0 +1,66 @@
import * as storage from '$lib/util/safeLocalStorage';
import {
DEFAULT_NORMALIZATION,
getNormalization,
isNormalizationPrefs,
putNormalization,
type NormalizationPrefs
} from '$lib/api/normalization';
// The signed-in user's normalization preference. The server holds it; this
// keeps the last value seen in localStorage too, so the player levels the
// first track of a session before the fetch returns.
const STORAGE_KEY = 'minstrel:normalization';
function readCached(): NormalizationPrefs {
try {
const v: unknown = JSON.parse(storage.read(STORAGE_KEY) ?? 'null');
return isNormalizationPrefs(v) ? v : DEFAULT_NORMALIZATION;
} catch {
return DEFAULT_NORMALIZATION;
}
}
let _prefs = $state<NormalizationPrefs>(readCached());
// Bumped by every change made here. A load or a save that resolves after a
// newer change must not overwrite it.
let _generation = 0;
export const normalization = {
get value(): NormalizationPrefs {
return _prefs;
}
};
function commit(p: NormalizationPrefs): void {
_prefs = p;
storage.write(STORAGE_KEY, JSON.stringify(p));
}
/** Refreshes from the server; on failure the cached value stands. */
export async function loadNormalization(): Promise<void> {
const gen = _generation;
try {
const p = await getNormalization();
if (gen === _generation) commit(p);
} catch {
// Offline or signed out: keep what we had.
}
}
/**
* Applies [next] at once, then saves it. If the save fails the previous
* value is restored and the error rethrown for the caller to report.
*/
export async function setNormalization(next: NormalizationPrefs): Promise<void> {
const prev = _prefs;
const gen = ++_generation;
commit(next);
try {
const saved = await putNormalization(next);
if (gen === _generation) commit(saved);
} catch (err) {
if (gen === _generation) commit(prev);
throw err;
}
}
+3
View File
@@ -30,6 +30,7 @@
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
import ServerVersion from '$lib/components/ServerVersion.svelte';
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
const queryClient = useQueryClient();
@@ -341,6 +342,8 @@
0 = off · most albums sound best at 0.
</p>
</div>
<NormalizationSetting />
</section>
<section class="space-y-4 rounded border border-border bg-surface p-4">
@@ -25,6 +25,16 @@ vi.mock('$lib/api/listenbrainz', () => {
};
});
// The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
return {
...mod,
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
putNormalization: vi.fn()
};
});
vi.mock('$lib/api/metrics', () => ({
createRecommendationMetricsQuery: () => ({
subscribe: (run: (v: unknown) => void) => {
+10
View File
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
setListenBrainzEnabled: vi.fn()
}));
// The Volume leveling setting loads on mount; keep it off the network.
vi.mock('$lib/api/normalization', async (importOriginal) => {
const mod = await importOriginal<typeof import('$lib/api/normalization')>();
return {
...mod,
getNormalization: vi.fn().mockResolvedValue(mod.DEFAULT_NORMALIZATION),
putNormalization: vi.fn()
};
});
vi.mock('$lib/api/me', () => ({
updateProfile: vi.fn(),
changePassword: vi.fn(),