mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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()
|
val transcodes: Boolean get() = maxBitrate > 0 || !format.isNullOrEmpty()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const val DEFAULT_SYNC_CONCURRENCY = 4
|
||||||
|
|
||||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
@@ -77,6 +79,14 @@ class UserPreferences(
|
|||||||
|
|
||||||
private fun streamQualityKey(mode: NetworkMode) = stringPreferencesKey("stream_quality_${mode.key}")
|
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? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
@@ -55,16 +56,18 @@ class SourceRepository(
|
|||||||
networkMode,
|
networkMode,
|
||||||
prefs.streamQuality(NetworkMode.Wifi),
|
prefs.streamQuality(NetworkMode.Wifi),
|
||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
) { config, mode, wifi, mobile -> config to (if (mode == NetworkMode.Wifi) wifi else mobile) }
|
prefs.syncConcurrency(),
|
||||||
.collect { (config, quality) ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
active = config?.toMusicSource(quality)
|
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
||||||
_quality.value = quality
|
}.collect { (config, quality, concurrency) ->
|
||||||
val sourceChanged = config?.id != activeSourceId
|
active = config?.toMusicSource(quality, concurrency)
|
||||||
activeSourceId = config?.id
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth) {
|
val sourceChanged = config?.id != activeSourceId
|
||||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
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? {
|
suspend fun activeMusicSource(): MusicSource? {
|
||||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
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 =
|
private fun SubsonicConfig.toClient(): SubsonicClient =
|
||||||
@@ -184,12 +187,16 @@ class SourceRepository(
|
|||||||
onTokenAuthUnsupported = { disableTokenAuth(id) },
|
onTokenAuthUnsupported = { disableTokenAuth(id) },
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun SubsonicConfig.toMusicSource(quality: StreamQuality): SubsonicMusicSource =
|
private fun SubsonicConfig.toMusicSource(
|
||||||
|
quality: StreamQuality,
|
||||||
|
syncConcurrency: Int,
|
||||||
|
): SubsonicMusicSource =
|
||||||
SubsonicMusicSource(
|
SubsonicMusicSource(
|
||||||
id = id,
|
id = id,
|
||||||
client = toClient(),
|
client = toClient(),
|
||||||
maxBitrate = quality.maxBitrate,
|
maxBitrate = quality.maxBitrate,
|
||||||
streamFormat = quality.format,
|
streamFormat = quality.format,
|
||||||
|
maxConcurrentFetches = syncConcurrency,
|
||||||
)
|
)
|
||||||
|
|
||||||
private fun disableTokenAuth(sourceId: Long) {
|
private fun disableTokenAuth(sourceId: Long) {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
import androidx.annotation.VisibleForTesting
|
|
||||||
import com.subtracks.data.model.Album
|
import com.subtracks.data.model.Album
|
||||||
import com.subtracks.data.model.Artist
|
import com.subtracks.data.model.Artist
|
||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
@@ -35,7 +34,6 @@ class SubsonicSource(
|
|||||||
private val maxBitrate: Int = 0,
|
private val maxBitrate: Int = 0,
|
||||||
private val streamFormat: String? = null,
|
private val streamFormat: String? = null,
|
||||||
private val maxPages: Int = MAX_PAGES,
|
private val maxPages: Int = MAX_PAGES,
|
||||||
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
|
|
||||||
private val maxConcurrentFetches: Int = MAX_CONCURRENT_FETCHES,
|
private val maxConcurrentFetches: Int = MAX_CONCURRENT_FETCHES,
|
||||||
) : MusicSource {
|
) : MusicSource {
|
||||||
private var emptyQuerySearchSupported: Boolean? = null
|
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 bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||||
private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "flac")
|
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
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -66,15 +66,18 @@ fun SettingsRoute(
|
|||||||
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
|
val activeSourceId by viewModel.activeSourceId.collectAsStateWithLifecycle()
|
||||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||||
|
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
activeSourceId = activeSourceId,
|
activeSourceId = activeSourceId,
|
||||||
wifiQuality = wifiQuality,
|
wifiQuality = wifiQuality,
|
||||||
mobileQuality = mobileQuality,
|
mobileQuality = mobileQuality,
|
||||||
|
syncConcurrency = syncConcurrency,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onDeleteSource = viewModel::deleteSource,
|
onDeleteSource = viewModel::deleteSource,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
onMobileQualityChange = viewModel::setMobileQuality,
|
onMobileQualityChange = viewModel::setMobileQuality,
|
||||||
|
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
)
|
)
|
||||||
@@ -87,10 +90,12 @@ fun SettingsScreen(
|
|||||||
activeSourceId: Long?,
|
activeSourceId: Long?,
|
||||||
wifiQuality: StreamQuality,
|
wifiQuality: StreamQuality,
|
||||||
mobileQuality: StreamQuality,
|
mobileQuality: StreamQuality,
|
||||||
|
syncConcurrency: Int,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onDeleteSource: (Long) -> Unit,
|
onDeleteSource: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||||
|
onSyncConcurrencyChange: (Int) -> Unit,
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -167,6 +172,14 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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)) }
|
item { Spacer(Modifier.height(fill)) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,6 +203,14 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncConcurrency -> {
|
||||||
|
ConcurrencyDialog(
|
||||||
|
selected = syncConcurrency,
|
||||||
|
onSelect = onSyncConcurrencyChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
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 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
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
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.StreamQuality
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
@@ -34,6 +35,11 @@ class SettingsViewModel(
|
|||||||
.streamQuality(NetworkMode.Mobile)
|
.streamQuality(NetworkMode.Mobile)
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), StreamQuality())
|
.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) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
@@ -52,4 +58,8 @@ class SettingsViewModel(
|
|||||||
fun setMobileQuality(quality: StreamQuality) {
|
fun setMobileQuality(quality: StreamQuality) {
|
||||||
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
|
viewModelScope.launch { userPreferences.setStreamQuality(NetworkMode.Mobile, quality) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSyncConcurrency(value: Int) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,24 @@ class UserPreferencesTest {
|
|||||||
file.delete()
|
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
|
@Test
|
||||||
fun lastSeedRoundTripsWithAndWithoutASecondary() =
|
fun lastSeedRoundTripsWithAndWithoutASecondary() =
|
||||||
runTest {
|
runTest {
|
||||||
|
|||||||
@@ -306,10 +306,12 @@ class AppScreenshotTest {
|
|||||||
activeSourceId = 1,
|
activeSourceId = 1,
|
||||||
wifiQuality = StreamQuality(),
|
wifiQuality = StreamQuality(),
|
||||||
mobileQuality = StreamQuality(192, "mp3"),
|
mobileQuality = StreamQuality(192, "mp3"),
|
||||||
|
syncConcurrency = 4,
|
||||||
onSelectSource = {},
|
onSelectSource = {},
|
||||||
onDeleteSource = {},
|
onDeleteSource = {},
|
||||||
onWifiQualityChange = {},
|
onWifiQualityChange = {},
|
||||||
onMobileQualityChange = {},
|
onMobileQualityChange = {},
|
||||||
|
onSyncConcurrencyChange = {},
|
||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onBack = {},
|
onBack = {},
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user