address review: test the source wiring, one default, clamp the stored value

- Add a `SourceRepositoryTest` that sets the preference to 1 and proves the
  active source fetches sequentially; removing the `maxConcurrentFetches`
  wiring now fails it.
- `playlistSongsRespectsASmallerFetchBound` now holds the first `bound`
  fetches and asserts no `(bound + 1)`th starts, instead of timing an
  overlap; it fails when the semaphore ignores the bound.
- One `DEFAULT_FETCH_CONCURRENCY` (in `data.source`) backs both the source
  default and the preference default, so they cannot drift.
- Clamp the stored preference in `syncConcurrency()` too, so a bad stored
  value can't make `SubsonicSource` throw on construction.
This commit is contained in:
austinried
2026-10-04 00:30:02 +00:00
parent a4149690e5
commit 5ade5073f8
7 changed files with 72 additions and 18 deletions
@@ -8,6 +8,7 @@ import androidx.datastore.preferences.core.intPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
@@ -43,8 +44,6 @@ 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(
@@ -79,7 +78,7 @@ 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 }
fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
suspend fun setSyncConcurrency(value: Int) {
store.edit { prefs -> prefs[syncConcurrencyKey] = value.coerceAtLeast(1) }
@@ -7,6 +7,8 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import kotlinx.coroutines.flow.Flow
const val DEFAULT_FETCH_CONCURRENCY = 4
interface MusicSource {
val id: Long
@@ -5,6 +5,7 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import kotlinx.coroutines.CancellationException
@@ -34,7 +35,7 @@ class SubsonicSource(
private val maxBitrate: Int = 0,
private val streamFormat: String? = null,
private val maxPages: Int = MAX_PAGES,
private val maxConcurrentFetches: Int = MAX_CONCURRENT_FETCHES,
private val maxConcurrentFetches: Int = DEFAULT_FETCH_CONCURRENCY,
) : MusicSource {
private var emptyQuerySearchSupported: Boolean? = null
@@ -306,8 +307,6 @@ class SubsonicSource(
}
}
internal const val MAX_CONCURRENT_FETCHES = 4
private class PageCapExceeded(
message: String,
) : SubsonicException(-1, message)
@@ -4,11 +4,11 @@ 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
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn
@@ -38,7 +38,7 @@ class SettingsViewModel(
val syncConcurrency: StateFlow<Int> =
userPreferences
.syncConcurrency()
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_CONCURRENCY)
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
fun selectSource(id: Long) {
viewModelScope.launch { sourceRepository.selectSource(id) }