From a4149690e563b3c23980813c18dd93a07205dc36 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Sun, 27 Sep 2026 08:28:18 +0000 Subject: [PATCH] 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 --- .../subtracks/data/prefs/UserPreferences.kt | 10 ++++ .../subtracks/data/repo/SourceRepository.kt | 29 +++++---- .../data/source/subsonic/SubsonicSource.kt | 2 - .../subtracks/ui/settings/SettingsScreen.kt | 59 ++++++++++++++++++- .../ui/settings/SettingsViewModel.kt | 10 ++++ .../data/prefs/UserPreferencesTest.kt | 18 ++++++ .../com/subtracks/ui/AppScreenshotTest.kt | 2 + 7 files changed, 116 insertions(+), 14 deletions(-) diff --git a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt index 1557b92c..acd3d200 100644 --- a/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt +++ b/app/src/main/kotlin/com/subtracks/data/prefs/UserPreferences.kt @@ -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 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 = 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 diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 5f5d4eac..9b298356 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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) { diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt index e849b5fd..35e9d432 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicSource.kt @@ -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 diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 521e3d79..de817361 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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, diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt index 0bbd7977..e1a2d25f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsViewModel.kt @@ -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 = + 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) } + } } diff --git a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt index e2aa6bd5..afed56d1 100644 --- a/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/prefs/UserPreferencesTest.kt @@ -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 { diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index d60e5b1a..a6125e84 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -306,10 +306,12 @@ class AppScreenshotTest { activeSourceId = 1, wifiQuality = StreamQuality(), mobileQuality = StreamQuality(192, "mp3"), + syncConcurrency = 4, onSelectSource = {}, onDeleteSource = {}, onWifiQualityChange = {}, onMobileQualityChange = {}, + onSyncConcurrencyChange = {}, onAddServer = {}, onBack = {}, )