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
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:
@@ -3,6 +3,7 @@ package com.fabledsword.minstrel.api.endpoints
|
|||||||
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
import com.fabledsword.minstrel.models.wire.ListenBrainzStatusWire
|
||||||
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
import com.fabledsword.minstrel.models.wire.MyProfileWire
|
||||||
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
import com.fabledsword.minstrel.models.wire.SystemPlaylistsStatusWire
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.serialization.SerialName
|
import kotlinx.serialization.SerialName
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import retrofit2.http.Body
|
import retrofit2.http.Body
|
||||||
@@ -59,6 +60,14 @@ interface MeApi {
|
|||||||
*/
|
*/
|
||||||
@PUT("api/me/listenbrainz")
|
@PUT("api/me/listenbrainz")
|
||||||
suspend fun setListenBrainz(@Body body: ListenBrainzPutBody): ListenBrainzStatusWire
|
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.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
import com.fabledsword.minstrel.cache.db.entities.AuthSessionEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Deferred
|
import kotlinx.coroutines.Deferred
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -77,6 +78,9 @@ class AuthStore @Inject constructor(
|
|||||||
private val diagnosticsOptOutState = MutableStateFlow(false)
|
private val diagnosticsOptOutState = MutableStateFlow(false)
|
||||||
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
val diagnosticsOptOut: StateFlow<Boolean> = diagnosticsOptOutState.asStateFlow()
|
||||||
|
|
||||||
|
private val normalizationState = MutableStateFlow(NormalizationPrefs.DEFAULT)
|
||||||
|
val normalization: StateFlow<NormalizationPrefs> = normalizationState.asStateFlow()
|
||||||
|
|
||||||
private val json = Json { ignoreUnknownKeys = true }
|
private val json = Json { ignoreUnknownKeys = true }
|
||||||
|
|
||||||
// Serialises every cookie persist with the one-time hydration, so a
|
// Serialises every cookie persist with the one-time hydration, so a
|
||||||
@@ -99,6 +103,7 @@ class AuthStore @Inject constructor(
|
|||||||
clientIdState.value = row?.clientId
|
clientIdState.value = row?.clientId
|
||||||
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
cacheSettingsState.value = decodeCacheSettings(row?.cacheSettingsJson)
|
||||||
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
diagnosticsOptOutState.value = row?.diagnosticsOptOut ?: false
|
||||||
|
normalizationState.value = decodeNormalization(row?.normalizationJson)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,6 +115,13 @@ class AuthStore @Inject constructor(
|
|||||||
}.getOrDefault(CacheSettings.DEFAULT)
|
}.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
|
* Suspends until the stored session cookie has been loaded into
|
||||||
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
* [sessionCookie], or [HYDRATION_DEADLINE_MS] passes (rule 156: a wedged
|
||||||
@@ -187,6 +199,12 @@ class AuthStore @Inject constructor(
|
|||||||
scope.launch { persistDiagnosticsOptOut(value) }
|
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?) {
|
private suspend fun persistLegacyCookie(value: String?) {
|
||||||
if (dao.get() == null) {
|
if (dao.get() == null) {
|
||||||
dao.upsert(currentEntity().copy(sessionCookie = value))
|
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(
|
private fun currentEntity(): AuthSessionEntity = AuthSessionEntity(
|
||||||
id = ROW_ID,
|
id = ROW_ID,
|
||||||
// Never copied into the row: the cookie lives in the vault, and
|
// Never copied into the row: the cookie lives in the vault, and
|
||||||
@@ -257,6 +283,10 @@ class AuthStore @Inject constructor(
|
|||||||
cacheSettingsState.value,
|
cacheSettingsState.value,
|
||||||
),
|
),
|
||||||
diagnosticsOptOut = diagnosticsOptOutState.value,
|
diagnosticsOptOut = diagnosticsOptOutState.value,
|
||||||
|
normalizationJson = json.encodeToString(
|
||||||
|
NormalizationPrefs.serializer(),
|
||||||
|
normalizationState.value,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
+15
-1
@@ -3,6 +3,8 @@ package com.fabledsword.minstrel.cache.db
|
|||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
import androidx.room.RoomDatabase
|
||||||
import androidx.room.TypeConverters
|
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.AudioCacheIndexDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
import com.fabledsword.minstrel.cache.db.dao.AuthSessionDao
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedAlbumDao
|
||||||
@@ -65,13 +67,18 @@ import com.fabledsword.minstrel.cache.db.entities.SyncMetadataEntity
|
|||||||
AuthSessionEntity::class,
|
AuthSessionEntity::class,
|
||||||
DiagnosticEventEntity::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),
|
// v8: + cached_tracks.missing, the server's missing-file mark (#2704),
|
||||||
// so cache-first surfaces stop offering files that cannot stream.
|
// so cache-first surfaces stop offering files that cannot stream.
|
||||||
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
// v7: + diagnostic_events table (M9) and the diagnosticsOptOut column
|
||||||
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
// on auth_session. Pre-v1 destructive fallback rebuilds on mismatch —
|
||||||
// which is exactly right here: the next sync refills every row with the
|
// which is exactly right here: the next sync refills every row with the
|
||||||
// new column populated, so there is nothing to migrate by hand.
|
// new column populated, so there is nothing to migrate by hand.
|
||||||
version = 8,
|
version = 9,
|
||||||
exportSchema = true,
|
exportSchema = true,
|
||||||
)
|
)
|
||||||
@TypeConverters(MinstrelTypeConverters::class)
|
@TypeConverters(MinstrelTypeConverters::class)
|
||||||
@@ -92,3 +99,10 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun authSessionDao(): AuthSessionDao
|
abstract fun authSessionDao(): AuthSessionDao
|
||||||
abstract fun diagnosticEventDao(): DiagnosticEventDao
|
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
|
// launch, so users lose only the unsynced mutation queue
|
||||||
// (acceptable while we're iterating). Replace with explicit
|
// (acceptable while we're iterating). Replace with explicit
|
||||||
// Migration entries before the first tagged release.
|
// Migration entries before the first tagged release.
|
||||||
|
.addMigrations(MIGRATION_8_9)
|
||||||
.fallbackToDestructiveMigration(dropAllTables = true)
|
.fallbackToDestructiveMigration(dropAllTables = true)
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
|||||||
+4
@@ -46,4 +46,8 @@ interface AuthSessionDao {
|
|||||||
/** Partial update: change only the per-device diagnostics opt-out. */
|
/** Partial update: change only the per-device diagnostics opt-out. */
|
||||||
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
@Query("UPDATE auth_session SET diagnosticsOptOut = :optOut WHERE id = 0")
|
||||||
suspend fun setDiagnosticsOptOut(optOut: Boolean)
|
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?)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
@@ -31,4 +31,8 @@ interface CachedMutationDao {
|
|||||||
|
|
||||||
@Query("DELETE FROM cached_mutations")
|
@Query("DELETE FROM cached_mutations")
|
||||||
suspend fun clear()
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
+6
@@ -43,4 +43,10 @@ data class AuthSessionEntity(
|
|||||||
* choice lives here. Default false = honor the account flag.
|
* choice lives here. Default false = honor the account flag.
|
||||||
*/
|
*/
|
||||||
val diagnosticsOptOut: Boolean = false,
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
+12
@@ -2,6 +2,7 @@ package com.fabledsword.minstrel.cache.mutations
|
|||||||
|
|
||||||
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
import com.fabledsword.minstrel.cache.db.dao.CachedMutationDao
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import kotlinx.coroutines.channels.BufferOverflow
|
import kotlinx.coroutines.channels.BufferOverflow
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.SharedFlow
|
import kotlinx.coroutines.flow.SharedFlow
|
||||||
@@ -41,6 +42,11 @@ object MutationKind {
|
|||||||
// an undo collapses to the latest intent instead of replaying as two
|
// an undo collapses to the latest intent instead of replaying as two
|
||||||
// opposed calls whose order decides the outcome.
|
// opposed calls whose order decides the outcome.
|
||||||
const val SUGGESTION_SNOOZE_TOGGLE: String = "suggestion_snooze_toggle"
|
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(
|
suspend fun enqueueRequestCancel(requestId: String): Long = insertUserDriven(
|
||||||
MutationKind.REQUEST_CANCEL,
|
MutationKind.REQUEST_CANCEL,
|
||||||
json.encodeToString(
|
json.encodeToString(
|
||||||
|
|||||||
+21
-1
@@ -7,6 +7,7 @@ import com.fabledsword.minstrel.api.endpoints.DiscoverApi
|
|||||||
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
import com.fabledsword.minstrel.api.endpoints.EventsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
import com.fabledsword.minstrel.api.endpoints.FlagRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.LikesApi
|
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.PlaybackErrorReportRequest
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
import com.fabledsword.minstrel.api.endpoints.PlaybackErrorsApi
|
||||||
import com.fabledsword.minstrel.api.endpoints.PlaylistsApi
|
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.cache.db.entities.CachedMutationEntity
|
||||||
import com.fabledsword.minstrel.di.ApplicationScope
|
import com.fabledsword.minstrel.di.ApplicationScope
|
||||||
import com.fabledsword.minstrel.likes.data.LikesRepository
|
import com.fabledsword.minstrel.likes.data.LikesRepository
|
||||||
|
import com.fabledsword.minstrel.settings.data.NormalizationPrefs
|
||||||
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
import com.fabledsword.minstrel.models.wire.CreateRequestBody
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
@@ -79,6 +81,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
private val eventsApi: EventsApi = retrofit.create()
|
private val eventsApi: EventsApi = retrofit.create()
|
||||||
private val requestsApi: RequestsApi = retrofit.create()
|
private val requestsApi: RequestsApi = retrofit.create()
|
||||||
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
private val playbackErrorsApi: PlaybackErrorsApi = retrofit.create()
|
||||||
|
private val meApi: MeApi = retrofit.create()
|
||||||
|
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -166,6 +169,7 @@ class MutationReplayer @Inject constructor(
|
|||||||
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
MutationKind.REQUEST_CANCEL -> dispatchRequestCancel(row.payload)
|
||||||
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
MutationKind.PLAYBACK_ERROR_REPORT -> dispatchPlaybackErrorReport(row.payload)
|
||||||
MutationKind.SUGGESTION_SNOOZE_TOGGLE -> dispatchSuggestionSnoozeToggle(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.
|
// Unknown kind — drop so a stale schema entry can't wedge the queue.
|
||||||
else -> Outcome.DROP
|
else -> Outcome.DROP
|
||||||
}
|
}
|
||||||
@@ -279,6 +283,16 @@ class MutationReplayer @Inject constructor(
|
|||||||
return Outcome.SENT
|
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 {
|
private suspend fun dispatchPlaybackErrorReport(payload: String): Outcome {
|
||||||
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
val decoded = json.decodeFromString(PlaybackErrorReportPayload.serializer(), payload)
|
||||||
playbackErrorsApi.report(
|
playbackErrorsApi.report(
|
||||||
@@ -303,7 +317,8 @@ class MutationReplayer @Inject constructor(
|
|||||||
/**
|
/**
|
||||||
* Row ids of desired-state toggles superseded by a later toggle for the same
|
* 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
|
* 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.
|
* 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
|
* 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)
|
json.decodeFromString(SuggestionSnoozeTogglePayload.serializer(), row.payload)
|
||||||
}.getOrNull()?.let { "${row.kind}:${it.mbid}" }
|
}.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
|
else -> null
|
||||||
}
|
}
|
||||||
|
|||||||
+55
@@ -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,
|
||||||
|
}
|
||||||
+67
@@ -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."
|
||||||
|
}
|
||||||
+44
@@ -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,
|
onToggle = onToggleDiagnostics,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
NormalizationCard()
|
||||||
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
AppearanceCard(themeMode = themeMode, onPick = onPickTheme)
|
||||||
StorageCard()
|
StorageCard()
|
||||||
AboutCard()
|
AboutCard()
|
||||||
|
|||||||
Vendored
+24
@@ -1,6 +1,8 @@
|
|||||||
package com.fabledsword.minstrel.cache.mutations
|
package com.fabledsword.minstrel.cache.mutations
|
||||||
|
|
||||||
import com.fabledsword.minstrel.cache.db.entities.CachedMutationEntity
|
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 kotlinx.serialization.json.Json
|
||||||
import org.junit.jupiter.api.Test
|
import org.junit.jupiter.api.Test
|
||||||
import kotlin.test.assertEquals
|
import kotlin.test.assertEquals
|
||||||
@@ -129,4 +131,26 @@ class SupersededToggleIdsTest {
|
|||||||
fun `an empty queue collapses nothing`() {
|
fun `an empty queue collapses nothing`() {
|
||||||
assertTrue(supersededToggleIds(emptyList(), json).isEmpty())
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+90
@@ -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()) }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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/password", h.handleChangePassword)
|
||||||
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
authed.Put("/me/profile", h.handleUpdateMyProfile)
|
||||||
authed.Put("/me/timezone", h.handlePutTimezone)
|
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.Post("/me/api-token", h.handleRegenerateMyAPIToken)
|
||||||
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
authed.Get("/me/subsonic-password", h.handleGetMySubsonicPassword)
|
||||||
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
authed.Post("/me/subsonic-password", h.handleGenerateMySubsonicPassword)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -784,6 +784,14 @@ type UserInvite struct {
|
|||||||
RedeemedBy pgtype.UUID
|
RedeemedBy pgtype.UUID
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type UserNormalizationPref struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
Mode string
|
||||||
|
TargetLufs int16
|
||||||
|
Boost string
|
||||||
|
UpdatedAt pgtype.Timestamptz
|
||||||
|
}
|
||||||
|
|
||||||
type YouMightLikeAlbum struct {
|
type YouMightLikeAlbum struct {
|
||||||
UserID pgtype.UUID
|
UserID pgtype.UUID
|
||||||
AlbumID 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;
|
||||||
@@ -93,6 +93,7 @@ var dataTables = []string{
|
|||||||
"track_fingerprints", // M400
|
"track_fingerprints", // M400
|
||||||
"track_loudness", // M464
|
"track_loudness", // M464
|
||||||
"album_loudness", // M464
|
"album_loudness", // M464
|
||||||
|
"user_normalization_prefs", // M464
|
||||||
"tracks",
|
"tracks",
|
||||||
"albums",
|
"albums",
|
||||||
"artists",
|
"artists",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,7 @@
|
|||||||
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||||
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
import ServerVersion from '$lib/components/ServerVersion.svelte';
|
||||||
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
import ActiveSessions from '$lib/components/ActiveSessions.svelte';
|
||||||
|
import NormalizationSetting from '$lib/components/NormalizationSetting.svelte';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -341,6 +342,8 @@
|
|||||||
0 = off · most albums sound best at 0.
|
0 = off · most albums sound best at 0.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<NormalizationSetting />
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4 rounded border border-border bg-surface p-4">
|
<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', () => ({
|
vi.mock('$lib/api/metrics', () => ({
|
||||||
createRecommendationMetricsQuery: () => ({
|
createRecommendationMetricsQuery: () => ({
|
||||||
subscribe: (run: (v: unknown) => void) => {
|
subscribe: (run: (v: unknown) => void) => {
|
||||||
|
|||||||
@@ -11,6 +11,16 @@ vi.mock('$lib/api/listenbrainz', () => ({
|
|||||||
setListenBrainzEnabled: vi.fn()
|
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', () => ({
|
vi.mock('$lib/api/me', () => ({
|
||||||
updateProfile: vi.fn(),
|
updateProfile: vi.fn(),
|
||||||
changePassword: vi.fn(),
|
changePassword: vi.fn(),
|
||||||
|
|||||||
Reference in New Issue
Block a user