mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.subtracks.data.prefs
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -65,7 +66,7 @@ class UserPreferencesTest {
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(DEFAULT_SYNC_CONCURRENCY, prefs.syncConcurrency().first())
|
||||
assertEquals(DEFAULT_FETCH_CONCURRENCY, prefs.syncConcurrency().first())
|
||||
|
||||
prefs.setSyncConcurrency(8)
|
||||
assertEquals(8, prefs.syncConcurrency().first())
|
||||
|
||||
@@ -9,15 +9,21 @@ import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.download.ArtworkStore
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import okhttp3.mockwebserver.RecordedRequest
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -26,10 +32,14 @@ import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.File
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SourceRepositoryTest {
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var prefs: UserPreferences
|
||||
private lateinit var repository: SourceRepository
|
||||
private lateinit var artwork: ArtworkStore
|
||||
private val messages = ArrayList<String>()
|
||||
@@ -43,7 +53,8 @@ class SourceRepositoryTest {
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
||||
repository = SourceRepository(db, OkHttpClient(), fakeUserPreferences(), artwork, showMessage = { messages += it })
|
||||
prefs = fakeUserPreferences()
|
||||
repository = SourceRepository(db, OkHttpClient(), prefs, artwork, showMessage = { messages += it })
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -88,6 +99,46 @@ class SourceRepositoryTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun theActiveSourceFetchConcurrencyComesFromThePreference() =
|
||||
runBlocking {
|
||||
withServer { server ->
|
||||
val dispatches = AtomicInteger()
|
||||
val secondStarted = CountDownLatch(1)
|
||||
val release = CountDownLatch(1)
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
||||
if (dispatches.incrementAndGet() == 2) secondStarted.countDown()
|
||||
release.await(5, TimeUnit.SECONDS)
|
||||
val id = url.queryParameter("id")!!
|
||||
return MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><playlist id=\"$id\">" +
|
||||
(1..3).joinToString("") { "<entry id=\"$id-s$it\" title=\"t$it\"/>" } +
|
||||
"</playlist></subsonic-response>",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
prefs.setSyncConcurrency(1)
|
||||
repository.addSource("nav", server.url("/").toString(), "u", "p", false)
|
||||
|
||||
val source = repository.activeMusicSource()!!
|
||||
val fetching =
|
||||
async(Dispatchers.IO) {
|
||||
source.playlistSongs(listOf("p1", "p2", "p3")).toList().flatten()
|
||||
}
|
||||
|
||||
// With concurrency 1 the second fetch must not start while the first is in flight;
|
||||
// if the preference is not wired the source defaults to a higher bound and it does.
|
||||
assertFalse(secondStarted.await(500, TimeUnit.MILLISECONDS))
|
||||
release.countDown()
|
||||
assertEquals(9, fetching.await().size)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pingReportsWhetherItFellBackToThePassword() =
|
||||
runBlocking {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -314,7 +315,7 @@ class SubsonicSourceTest {
|
||||
assertEquals(listOf(0L, 1L, 2L), batch.sortedBy { it.position }.map { it.position })
|
||||
assertEquals((1..3).map { "$playlistId-s$it" }, batch.sortedBy { it.position }.map { it.songId })
|
||||
}
|
||||
assertTrue(maxInFlight.get() in 2..MAX_CONCURRENT_FETCHES)
|
||||
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -349,20 +350,17 @@ class SubsonicSourceTest {
|
||||
fun playlistSongsRespectsASmallerFetchBound() =
|
||||
runBlocking {
|
||||
val bound = 2
|
||||
val inFlight = AtomicInteger()
|
||||
val maxInFlight = AtomicInteger()
|
||||
val started = CountDownLatch(bound)
|
||||
val beyondBound = CountDownLatch(bound + 1)
|
||||
val release = CountDownLatch(1)
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
||||
val current = inFlight.incrementAndGet()
|
||||
maxInFlight.updateAndGet { maxOf(it, current) }
|
||||
beyondBound.countDown()
|
||||
started.countDown()
|
||||
release.await(5, TimeUnit.SECONDS)
|
||||
inFlight.decrementAndGet()
|
||||
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
|
||||
}
|
||||
}
|
||||
@@ -376,11 +374,15 @@ class SubsonicSourceTest {
|
||||
}
|
||||
|
||||
assertTrue("$bound fetches should have started together", started.await(5, TimeUnit.SECONDS))
|
||||
// While those are held, the semaphore must block any further fetch.
|
||||
assertFalse(
|
||||
"a ${bound + 1}th fetch started past the bound",
|
||||
beyondBound.await(500, TimeUnit.MILLISECONDS),
|
||||
)
|
||||
release.countDown()
|
||||
val entries = fetching.await()
|
||||
|
||||
assertEquals(12, entries.size)
|
||||
assertEquals(bound, maxInFlight.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -496,7 +498,7 @@ class SubsonicSourceTest {
|
||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||
|
||||
assertEquals(4, songs.size)
|
||||
assertTrue(maxInFlight.get() in 2..MAX_CONCURRENT_FETCHES)
|
||||
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user