wire the sync fetch concurrency to a Settings option

The bound was only a constructor default with no production consumer. Add a
"Sync concurrency" preference (Settings -> Network, default 4) and pass it
through SourceRepository to SubsonicSource, so sync parallelism is tunable
without a rebuild. Both the app-scoped source and the on-demand
`activeMusicSource()` used by sync read the preference.

- UserPreferences.syncConcurrency()/setSyncConcurrency() (clamped >= 1)
- SourceRepository combines the preference into toMusicSource
- Settings row + dialog; SettingsViewModel state and setter
- Preferences test for default/round-trip/clamp
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent adcdc442e4
commit a4149690e5
7 changed files with 116 additions and 14 deletions
@@ -43,6 +43,8 @@ data class StreamQuality(
val transcodes: Boolean get() = maxBitrate > 0 || !format.isNullOrEmpty()
}
const val DEFAULT_SYNC_CONCURRENCY = 4
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
data class ArtworkSeedValue(
@@ -77,6 +79,14 @@ class UserPreferences(
private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> prefs[syncConcurrencyKey] ?: DEFAULT_SYNC_CONCURRENCY }
suspend fun setSyncConcurrency(value: Int) {
store.edit { prefs -> prefs[syncConcurrencyKey] = value.coerceAtLeast(1) }
}
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
suspend fun lastSeed(): ArtworkSeedValue? {
val prefs = store.data.first()
val cacheKey = prefs[lastSeedKey] ?: return null
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -55,16 +56,18 @@ class SourceRepository(
networkMode,
prefs.streamQuality(NetworkMode.Wifi),
prefs.streamQuality(NetworkMode.Mobile),
) { config, mode, wifi, mobile -> config to (if (mode == NetworkMode.Wifi) wifi else mobile) }
.collect { (config, quality) ->
active = config?.toMusicSource(quality)
_quality.value = quality
val sourceChanged = config?.id != activeSourceId
activeSourceId = config?.id
if (sourceChanged && config != null && config.useTokenAuth) {
scope.launch { runCatching { config.toClient().check("ping") } }
}
prefs.syncConcurrency(),
) { config, mode, wifi, mobile, concurrency ->
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
}.collect { (config, quality, concurrency) ->
active = config?.toMusicSource(quality, concurrency)
_quality.value = quality
val sourceChanged = config?.id != activeSourceId
activeSourceId = config?.id
if (sourceChanged && config != null && config.useTokenAuth) {
scope.launch { runCatching { config.toClient().check("ping") } }
}
}
}
}
@@ -172,7 +175,7 @@ class SourceRepository(
suspend fun activeMusicSource(): MusicSource? {
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
return config.toMusicSource(quality.value)
return config.toMusicSource(quality.value, prefs.syncConcurrency().first())
}
private fun SubsonicConfig.toClient(): SubsonicClient =
@@ -184,12 +187,16 @@ class SourceRepository(
onTokenAuthUnsupported = { disableTokenAuth(id) },
)
private fun SubsonicConfig.toMusicSource(quality: StreamQuality): SubsonicMusicSource =
private fun SubsonicConfig.toMusicSource(
quality: StreamQuality,
syncConcurrency: Int,
): SubsonicMusicSource =
SubsonicMusicSource(
id = id,
client = toClient(),
maxBitrate = quality.maxBitrate,
streamFormat = quality.format,
maxConcurrentFetches = syncConcurrency,
)
private fun disableTokenAuth(sourceId: Long) {
@@ -1,6 +1,5 @@
package com.subtracks.data.source.subsonic
import androidx.annotation.VisibleForTesting
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
@@ -35,7 +34,6 @@ class SubsonicSource(
private val maxBitrate: Int = 0,
private val streamFormat: String? = null,
private val maxPages: Int = MAX_PAGES,
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
private val maxConcurrentFetches: Int = MAX_CONCURRENT_FETCHES,
) : MusicSource {
private var emptyQuerySearchSupported: Boolean? = null
@@ -54,7 +54,7 @@ import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac")
private enum class SettingsDialog { WifiQuality, MobileQuality }
private enum class SettingsDialog { WifiQuality, MobileQuality, SyncConcurrency }
@Composable
fun SettingsRoute(
@@ -66,15 +66,18 @@ fun SettingsRoute(
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
wifiQuality = wifiQuality,
mobileQuality = mobileQuality,
syncConcurrency = syncConcurrency,
onSelectSource = viewModel::selectSource,
onDeleteSource = viewModel::deleteSource,
onWifiQualityChange = viewModel::setWifiQuality,
onMobileQualityChange = viewModel::setMobileQuality,
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
onAddServer = onAddServer,
onBack = onBack,
)
@@ -87,10 +90,12 @@ fun SettingsScreen(
activeSourceId: Long?,
wifiQuality: StreamQuality,
mobileQuality: StreamQuality,
syncConcurrency: Int,
onSelectSource: (Long) -> Unit,
onDeleteSource: (Long) -> Unit,
onWifiQualityChange: (StreamQuality) -> Unit,
onMobileQualityChange: (StreamQuality) -> Unit,
onSyncConcurrencyChange: (Int) -> Unit,
onAddServer: () -> Unit,
onBack: () -> Unit,
modifier: Modifier = Modifier,
@@ -167,6 +172,14 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text("Sync concurrency") },
supportingContent = { Text(concurrencyLabel(syncConcurrency)) },
modifier = Modifier.clickable { dialog = SettingsDialog.SyncConcurrency },
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { Spacer(Modifier.height(fill)) }
}
}
@@ -190,6 +203,14 @@ fun SettingsScreen(
)
}
SettingsDialog.SyncConcurrency -> {
ConcurrencyDialog(
selected = syncConcurrency,
onSelect = onSyncConcurrencyChange,
onDismiss = { dialog = null },
)
}
null -> {
Unit
}
@@ -200,6 +221,42 @@ private fun qualityLabel(quality: StreamQuality): String = "${bitrateLabel(quali
private fun bitrateLabel(kbps: Int): String = if (kbps == 0) "Unlimited" else "${kbps}kbps"
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
private fun concurrencyLabel(value: Int): String = if (value <= 1) "1 (sequential)" else value.toString()
@Composable
private fun ConcurrencyDialog(
selected: Int,
onSelect: (Int) -> Unit,
onDismiss: () -> Unit,
) {
var draft by remember(selected) { mutableStateOf(selected) }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text("Sync concurrency") },
text = {
ChoiceGroup(
header = "Parallel server requests while syncing",
options = syncConcurrencyOptions.map { it to concurrencyLabel(it) },
selected = draft,
onSelect = { draft = it },
)
},
confirmButton = {
TextButton(
onClick = {
onSelect(draft)
onDismiss()
},
) { Text("Done") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable
private fun QualityDialog(
title: String,
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.subtracks.data.model.Source
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_CONCURRENCY
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.DownloadRepository
@@ -34,6 +35,11 @@ class SettingsViewModel(
.streamQuality(NetworkMode.Mobile)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
val syncConcurrency: StateFlow<Int> =
userPreferences
.syncConcurrency()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_CONCURRENCY)
fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) }
}
@@ -52,4 +58,8 @@ class SettingsViewModel(
fun setMobileQuality(quality: StreamQuality) {
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
}
fun setSyncConcurrency(value: Int) {
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
}
}
@@ -58,6 +58,24 @@ class UserPreferencesTest {
file.delete()
}
@Test
fun syncConcurrencyDefaultsAndClampsToAtLeastOne() =
runTest {
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
val prefs = UserPreferences(store)
assertEquals(DEFAULT_SYNC_CONCURRENCY, prefs.syncConcurrency().first())
prefs.setSyncConcurrency(8)
assertEquals(8, prefs.syncConcurrency().first())
prefs.setSyncConcurrency(0)
assertEquals(1, prefs.syncConcurrency().first())
file.delete()
}
@Test
fun lastSeedRoundTripsWithAndWithoutASecondary() =
runTest {
@@ -306,10 +306,12 @@ class AppScreenshotTest {
activeSourceId = 1,
wifiQuality = StreamQuality(),
mobileQuality = StreamQuality(192, "mp3"),
syncConcurrency = 4,
onSelectSource = {},
onDeleteSource = {},
onWifiQualityChange = {},
onMobileQualityChange = {},
onSyncConcurrencyChange = {},
onAddServer = {},
onBack = {},
)