mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 15:29:15 +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.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.map
|
import kotlinx.coroutines.flow.map
|
||||||
@@ -43,8 +44,6 @@ 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(
|
||||||
@@ -79,7 +78,7 @@ 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 }
|
fun syncConcurrency(): Flow<Int> = store.data.map { prefs -> (prefs[syncConcurrencyKey] ?: DEFAULT_FETCH_CONCURRENCY).coerceAtLeast(1) }
|
||||||
|
|
||||||
suspend fun setSyncConcurrency(value: Int) {
|
suspend fun setSyncConcurrency(value: Int) {
|
||||||
store.edit { prefs -> prefs[syncConcurrencyKey] = value.coerceAtLeast(1) }
|
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 com.subtracks.data.model.Song
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
const val DEFAULT_FETCH_CONCURRENCY = 4
|
||||||
|
|
||||||
interface MusicSource {
|
interface MusicSource {
|
||||||
val id: Long
|
val id: Long
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import com.subtracks.data.model.Artist
|
|||||||
import com.subtracks.data.model.Playlist
|
import com.subtracks.data.model.Playlist
|
||||||
import com.subtracks.data.model.PlaylistSong
|
import com.subtracks.data.model.PlaylistSong
|
||||||
import com.subtracks.data.model.Song
|
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.MusicSource
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
@@ -34,7 +35,7 @@ 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,
|
||||||
private val maxConcurrentFetches: Int = MAX_CONCURRENT_FETCHES,
|
private val maxConcurrentFetches: Int = DEFAULT_FETCH_CONCURRENCY,
|
||||||
) : MusicSource {
|
) : MusicSource {
|
||||||
private var emptyQuerySearchSupported: Boolean? = null
|
private var emptyQuerySearchSupported: Boolean? = null
|
||||||
|
|
||||||
@@ -306,8 +307,6 @@ class SubsonicSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal const val MAX_CONCURRENT_FETCHES = 4
|
|
||||||
|
|
||||||
private class PageCapExceeded(
|
private class PageCapExceeded(
|
||||||
message: String,
|
message: String,
|
||||||
) : SubsonicException(-1, message)
|
) : SubsonicException(-1, message)
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ 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
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
@@ -38,7 +38,7 @@ class SettingsViewModel(
|
|||||||
val syncConcurrency: StateFlow<Int> =
|
val syncConcurrency: StateFlow<Int> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.syncConcurrency()
|
.syncConcurrency()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_CONCURRENCY)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||||
|
|
||||||
fun selectSource(id: Long) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package com.subtracks.data.prefs
|
|||||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
@@ -65,7 +66,7 @@ class UserPreferencesTest {
|
|||||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||||
val prefs = UserPreferences(store)
|
val prefs = UserPreferences(store)
|
||||||
|
|
||||||
assertEquals(DEFAULT_SYNC_CONCURRENCY, prefs.syncConcurrency().first())
|
assertEquals(DEFAULT_FETCH_CONCURRENCY, prefs.syncConcurrency().first())
|
||||||
|
|
||||||
prefs.setSyncConcurrency(8)
|
prefs.setSyncConcurrency(8)
|
||||||
assertEquals(8, prefs.syncConcurrency().first())
|
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.download.ArtworkStore
|
||||||
import com.subtracks.data.model.CoverArtRef
|
import com.subtracks.data.model.CoverArtRef
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.toList
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.mockwebserver.Dispatcher
|
||||||
import okhttp3.mockwebserver.MockResponse
|
import okhttp3.mockwebserver.MockResponse
|
||||||
import okhttp3.mockwebserver.MockWebServer
|
import okhttp3.mockwebserver.MockWebServer
|
||||||
|
import okhttp3.mockwebserver.RecordedRequest
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -26,10 +32,14 @@ import org.junit.Before
|
|||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class SourceRepositoryTest {
|
class SourceRepositoryTest {
|
||||||
private lateinit var db: SubtracksDatabase
|
private lateinit var db: SubtracksDatabase
|
||||||
|
private lateinit var prefs: UserPreferences
|
||||||
private lateinit var repository: SourceRepository
|
private lateinit var repository: SourceRepository
|
||||||
private lateinit var artwork: ArtworkStore
|
private lateinit var artwork: ArtworkStore
|
||||||
private val messages = ArrayList<String>()
|
private val messages = ArrayList<String>()
|
||||||
@@ -43,7 +53,8 @@ class SourceRepositoryTest {
|
|||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(BundledSQLiteDriver())
|
||||||
.build()
|
.build()
|
||||||
artwork = ArtworkStore(File(context.cacheDir, "art-${System.nanoTime()}"))
|
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
|
@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
|
@Test
|
||||||
fun pingReportsWhetherItFellBackToThePassword() =
|
fun pingReportsWhetherItFellBackToThePassword() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.subtracks.data.source.subsonic
|
package com.subtracks.data.source.subsonic
|
||||||
|
|
||||||
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -314,7 +315,7 @@ class SubsonicSourceTest {
|
|||||||
assertEquals(listOf(0L, 1L, 2L), batch.sortedBy { it.position }.map { it.position })
|
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 })
|
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
|
@Test
|
||||||
@@ -349,20 +350,17 @@ class SubsonicSourceTest {
|
|||||||
fun playlistSongsRespectsASmallerFetchBound() =
|
fun playlistSongsRespectsASmallerFetchBound() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
val bound = 2
|
val bound = 2
|
||||||
val inFlight = AtomicInteger()
|
|
||||||
val maxInFlight = AtomicInteger()
|
|
||||||
val started = CountDownLatch(bound)
|
val started = CountDownLatch(bound)
|
||||||
|
val beyondBound = CountDownLatch(bound + 1)
|
||||||
val release = CountDownLatch(1)
|
val release = CountDownLatch(1)
|
||||||
server.dispatcher =
|
server.dispatcher =
|
||||||
object : Dispatcher() {
|
object : Dispatcher() {
|
||||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
val url = request.requestUrl!!
|
val url = request.requestUrl!!
|
||||||
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
if (url.encodedPath != "/rest/getPlaylist.view") return MockResponse().setResponseCode(404)
|
||||||
val current = inFlight.incrementAndGet()
|
beyondBound.countDown()
|
||||||
maxInFlight.updateAndGet { maxOf(it, current) }
|
|
||||||
started.countDown()
|
started.countDown()
|
||||||
release.await(5, TimeUnit.SECONDS)
|
release.await(5, TimeUnit.SECONDS)
|
||||||
inFlight.decrementAndGet()
|
|
||||||
return MockResponse().setBody(playlistResponse(url.queryParameter("id")!!))
|
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))
|
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()
|
release.countDown()
|
||||||
val entries = fetching.await()
|
val entries = fetching.await()
|
||||||
|
|
||||||
assertEquals(12, entries.size)
|
assertEquals(12, entries.size)
|
||||||
assertEquals(bound, maxInFlight.get())
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -496,7 +498,7 @@ class SubsonicSourceTest {
|
|||||||
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
val songs = SubsonicSource(1, client()).songs().toList().flatten()
|
||||||
|
|
||||||
assertEquals(4, songs.size)
|
assertEquals(4, songs.size)
|
||||||
assertTrue(maxInFlight.get() in 2..MAX_CONCURRENT_FETCHES)
|
assertTrue(maxInFlight.get() in 2..DEFAULT_FETCH_CONCURRENCY)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
Reference in New Issue
Block a user