mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
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:
@@ -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 = {},
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user