mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +01:00
address review: serialize syncs, classify parse errors, widen coverage
- SourceRepository now holds a Mutex around sync(), so overlapping requestSync() calls can no longer interleave per-batch writes and stale-set prunes. The per-call SyncService lock could never do this. - parse() wraps SAXParseException/IOException as SubsonicException, so a truncated or malformed response is a clean sync failure instead of a raw parser exception escaping the empty-search fallback. - A response with no status attribute is accepted again, matching the old DOM behaviour (only status="failed" fails). - Simplify the SAX handler (drop sawRoot and StatusHandler), make the entity mappers internal, and drop an unreachable guard in write(). Tests: pagination and empty-search caching for songs, album pagination, empty-id filtering, cancellation, SAX error paths and structural edge cases, cross-source prune isolation, emptied playlists, disc-set shrink, multi-chunk deletes, change detection via total_changes, playlist id filtering, and SyncManager cache invalidation on failure.
This commit is contained in:
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -27,6 +29,7 @@ class SourceRepository(
|
||||
private val prefs: UserPreferences,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val syncLock = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var active: SubsonicMusicSource? = null
|
||||
@@ -116,9 +119,11 @@ class SourceRepository(
|
||||
}
|
||||
|
||||
suspend fun sync(): Result<Unit> =
|
||||
runCatching {
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
|
||||
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
|
||||
syncLock.withLock {
|
||||
runCatching {
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
|
||||
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
|
||||
}
|
||||
}
|
||||
|
||||
private fun SubsonicConfig.toMusicSource(
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.subtracks.data.model.Song
|
||||
import org.xml.sax.Attributes
|
||||
import org.xml.sax.SAXException
|
||||
import org.xml.sax.helpers.DefaultHandler
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.time.LocalDate
|
||||
import java.time.OffsetDateTime
|
||||
@@ -17,7 +18,7 @@ import javax.xml.parsers.ParserConfigurationException
|
||||
import javax.xml.parsers.SAXParserFactory
|
||||
|
||||
object SubsonicXml {
|
||||
fun artist(
|
||||
internal fun artist(
|
||||
sourceId: Long,
|
||||
attrs: Attributes,
|
||||
): Artist =
|
||||
@@ -48,7 +49,7 @@ object SubsonicXml {
|
||||
songCount = attrs.longAttr("songCount") ?: 0L,
|
||||
)
|
||||
|
||||
fun song(
|
||||
internal fun song(
|
||||
sourceId: Long,
|
||||
attrs: Attributes,
|
||||
): Song =
|
||||
@@ -68,7 +69,7 @@ object SubsonicXml {
|
||||
created = attrs.dateAttr("created") ?: 0L,
|
||||
)
|
||||
|
||||
fun playlist(
|
||||
internal fun playlist(
|
||||
sourceId: Long,
|
||||
attrs: Attributes,
|
||||
): Playlist =
|
||||
@@ -84,7 +85,7 @@ object SubsonicXml {
|
||||
duration = attrs.longAttr("duration") ?: 0L,
|
||||
)
|
||||
|
||||
fun playlistSong(
|
||||
internal fun playlistSong(
|
||||
sourceId: Long,
|
||||
playlistId: String,
|
||||
position: Long,
|
||||
@@ -98,7 +99,7 @@ object SubsonicXml {
|
||||
)
|
||||
|
||||
internal fun readStatus(input: InputStream) {
|
||||
parse(input, StatusHandler())
|
||||
parse(input, SubsonicResponseHandler())
|
||||
}
|
||||
|
||||
internal fun <T> readEntities(
|
||||
@@ -175,9 +176,8 @@ private class XmlFailure(
|
||||
|
||||
private class StopReading : RuntimeException()
|
||||
|
||||
private abstract class SubsonicResponseHandler : DefaultHandler() {
|
||||
private open class SubsonicResponseHandler : DefaultHandler() {
|
||||
private var depth = 0
|
||||
private var sawRoot = false
|
||||
private var statusFailed = false
|
||||
|
||||
override fun startElement(
|
||||
@@ -187,10 +187,9 @@ private abstract class SubsonicResponseHandler : DefaultHandler() {
|
||||
attributes: Attributes,
|
||||
) {
|
||||
depth++
|
||||
if (!sawRoot) {
|
||||
sawRoot = true
|
||||
if (depth == 1) {
|
||||
if (qName != ROOT_TAG) throw XmlFailure(-1, "Unexpected response from the server")
|
||||
if (attributes.getValue("status") != "ok") statusFailed = true
|
||||
if (attributes.getValue("status") == "failed") statusFailed = true
|
||||
}
|
||||
if (qName == "error") {
|
||||
throw XmlFailure(
|
||||
@@ -226,8 +225,6 @@ private abstract class SubsonicResponseHandler : DefaultHandler() {
|
||||
) = Unit
|
||||
}
|
||||
|
||||
private class StatusHandler : SubsonicResponseHandler()
|
||||
|
||||
private class EntityHandler<T>(
|
||||
private val entityTag: String,
|
||||
private val create: (Attributes) -> T,
|
||||
@@ -281,6 +278,10 @@ private fun parse(
|
||||
} catch (failure: XmlFailure) {
|
||||
throw SubsonicException(failure.code, failure.message ?: "Unknown error")
|
||||
} catch (_: StopReading) {
|
||||
} catch (failure: SAXException) {
|
||||
throw SubsonicException(-1, failure.message ?: "Malformed response from the server")
|
||||
} catch (failure: IOException) {
|
||||
throw SubsonicException(-1, failure.message ?: "Truncated response from the server")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,23 +13,18 @@ import com.subtracks.data.model.Playlist
|
||||
import com.subtracks.data.model.PlaylistSong
|
||||
import com.subtracks.data.model.Song
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
|
||||
class SyncService(
|
||||
private val db: SubtracksDatabase,
|
||||
private val source: MusicSource,
|
||||
) {
|
||||
private val lock = Mutex()
|
||||
|
||||
suspend fun sync() =
|
||||
lock.withLock {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
}
|
||||
suspend fun sync() {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
}
|
||||
|
||||
private suspend fun syncArtists() {
|
||||
val library = db.libraryDao()
|
||||
@@ -102,7 +97,6 @@ class SyncService(
|
||||
rows: List<T>,
|
||||
values: (T) -> List<Any?>,
|
||||
) {
|
||||
if (rows.isEmpty()) return
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.upsertChanged(table, columns, key, rows, values)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package com.subtracks.data.source.subsonic
|
||||
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.toList
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.mockwebserver.Dispatcher
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
@@ -13,6 +15,8 @@ import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CopyOnWriteArrayList
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class SubsonicSourceTest {
|
||||
private lateinit var server: MockWebServer
|
||||
@@ -83,6 +87,98 @@ class SubsonicSourceTest {
|
||||
assertEquals(listOf(500, 500, 200), sizes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun albumsPaginateUntilAShortPage() =
|
||||
runBlocking {
|
||||
val requestedOffsets = CopyOnWriteArrayList<Int>()
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
if (url.encodedPath != "/rest/getAlbumList2.view") return MockResponse().setResponseCode(404)
|
||||
if (url.queryParameter("type") != "newest") {
|
||||
return MockResponse().setBody(emptyAlbumList())
|
||||
}
|
||||
val offset = url.queryParameter("offset")!!.toInt()
|
||||
requestedOffsets += offset
|
||||
val count = if (offset < 1000) 500 else 3
|
||||
return MockResponse().setBody(albumPage(offset, count))
|
||||
}
|
||||
}
|
||||
|
||||
val albums = SubsonicSource(1, client()).albums().toList().flatten()
|
||||
|
||||
assertEquals(1003, albums.size)
|
||||
assertEquals(listOf(0, 500, 1000), requestedOffsets.toList())
|
||||
assertEquals("al-0", albums.first().id)
|
||||
assertEquals("al-1002", albums.last().id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun songsUseEmptySearchAndPaginate() =
|
||||
runBlocking {
|
||||
val probeCount = AtomicInteger()
|
||||
val requestedOffsets = CopyOnWriteArrayList<Int>()
|
||||
server.dispatcher =
|
||||
object : Dispatcher() {
|
||||
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||
val url = request.requestUrl!!
|
||||
if (url.encodedPath != "/rest/search3.view") return MockResponse().setResponseCode(404)
|
||||
if (url.queryParameter("songCount") == "1") {
|
||||
probeCount.incrementAndGet()
|
||||
return MockResponse().setBody(songPage("probe", 0, 1))
|
||||
}
|
||||
val offset = url.queryParameter("songOffset")!!.toInt()
|
||||
requestedOffsets += offset
|
||||
val count = if (offset < 1000) 500 else 3
|
||||
return MockResponse().setBody(songPage("s", offset, count))
|
||||
}
|
||||
}
|
||||
val source = SubsonicSource(1, client())
|
||||
|
||||
val first = source.songs().toList().flatten()
|
||||
val firstOffsets = requestedOffsets.toList()
|
||||
val second = source.songs().toList().flatten()
|
||||
|
||||
assertEquals(1003, first.size)
|
||||
assertEquals(1003, second.size)
|
||||
assertEquals(1, probeCount.get())
|
||||
assertEquals(listOf(0, 500, 1000), firstOffsets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun entitiesWithEmptyIdsAreDropped() =
|
||||
runBlocking {
|
||||
server.enqueue(
|
||||
MockResponse().setBody(
|
||||
"<subsonic-response status=\"ok\"><artists>" +
|
||||
"<artist id=\"\" name=\"No id\"/>" +
|
||||
"<artist id=\"ar1\" name=\"Real\"/>" +
|
||||
"</artists></subsonic-response>",
|
||||
),
|
||||
)
|
||||
|
||||
val artists = SubsonicSource(1, client()).artists().toList().flatten()
|
||||
|
||||
assertEquals(listOf("ar1"), artists.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingAfterTheFirstBatchDoesNotHang() =
|
||||
runBlocking {
|
||||
val body =
|
||||
buildString {
|
||||
append("<subsonic-response status=\"ok\"><artists>")
|
||||
repeat(100_000) { append("<artist id=\"ar$it\" name=\"A$it\" albumCount=\"1\"/>") }
|
||||
append("</artists></subsonic-response>")
|
||||
}
|
||||
server.enqueue(MockResponse().setBody(body))
|
||||
|
||||
val firstBatch = withTimeout(15_000) { SubsonicSource(1, client()).artists().first() }
|
||||
|
||||
assertEquals(500, firstBatch.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
|
||||
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
|
||||
@@ -122,6 +218,27 @@ class SubsonicSourceTest {
|
||||
message: String,
|
||||
) = "<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>"
|
||||
|
||||
private fun emptyAlbumList() = "<subsonic-response status=\"ok\"><albumList2></albumList2></subsonic-response>"
|
||||
|
||||
private fun albumPage(
|
||||
offset: Int,
|
||||
count: Int,
|
||||
) = "<subsonic-response status=\"ok\"><albumList2>" +
|
||||
(offset until offset + count).joinToString("") {
|
||||
"<album id=\"al-$it\" name=\"Album $it\" artist=\"Artist\" artistId=\"ar1\" songCount=\"1\"/>"
|
||||
} +
|
||||
"</albumList2></subsonic-response>"
|
||||
|
||||
private fun songPage(
|
||||
prefix: String,
|
||||
offset: Int,
|
||||
count: Int,
|
||||
) = "<subsonic-response status=\"ok\"><searchResult3>" +
|
||||
(offset until offset + count).joinToString("") {
|
||||
"<song id=\"$prefix$it\" title=\"Song $it\"/>"
|
||||
} +
|
||||
"</searchResult3></subsonic-response>"
|
||||
|
||||
private companion object {
|
||||
const val ALBUM_LIST =
|
||||
"<subsonic-response status=\"ok\" version=\"1.16.1\"><albumList2>" +
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package com.subtracks.data.source.subsonic
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import org.xml.sax.Attributes
|
||||
import java.io.FilterInputStream
|
||||
import java.io.InputStream
|
||||
|
||||
class SubsonicXmlTest {
|
||||
private fun <T> parse(
|
||||
@@ -156,4 +159,146 @@ class SubsonicXmlTest {
|
||||
assertEquals(true, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "song"))
|
||||
assertEquals(false, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "artist"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun containsEntityStopsReadingAtTheFirstMatch() {
|
||||
val xml =
|
||||
buildString {
|
||||
append("<subsonic-response status=\"ok\"><searchResult3>")
|
||||
repeat(5_000) { append("<song id=\"s$it\" title=\"Song $it\"/>") }
|
||||
append("</searchResult3></subsonic-response>")
|
||||
}
|
||||
val counting = CountingInputStream(xml.byteInputStream(Charsets.UTF_8))
|
||||
|
||||
assertEquals(true, SubsonicXml.containsEntity(counting, "song"))
|
||||
|
||||
assertTrue("read ${counting.bytesRead} of ${xml.length}", counting.bytesRead < 32_000)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsATruncatedResponse() {
|
||||
val failure =
|
||||
runCatching {
|
||||
SubsonicXml.readStatus("<subsonic-response status=\"ok\"><artists>".byteInputStream(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedStatusWithoutAnErrorStillThrows() {
|
||||
val failure =
|
||||
runCatching {
|
||||
SubsonicXml.readStatus(
|
||||
"<subsonic-response status=\"failed\"></subsonic-response>".byteInputStream(Charsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorElementWithoutStatusThrowsWithItsCode() {
|
||||
val error =
|
||||
runCatching {
|
||||
SubsonicXml.readStatus(
|
||||
"<subsonic-response><error code=\"40\" message=\"Wrong username\"/></subsonic-response>"
|
||||
.byteInputStream(Charsets.UTF_8),
|
||||
)
|
||||
}.exceptionOrNull() as? SubsonicException
|
||||
|
||||
assertEquals(40, error?.code)
|
||||
assertEquals("Wrong username", error?.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedSameNameElementsEmitOnce() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="Outer">
|
||||
<album id="al2" name="Inner"/>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(listOf("al1"), albums.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ignoresDiscTitlesThatAreNotDirectChildren() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="The Wall">
|
||||
<wrapper><discTitles disc="1" title="The Calm"/></wrapper>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(emptyMap<Long, String>(), albums.single().discTitles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discTitleWithoutADiscNumberIsIgnored() {
|
||||
val albums =
|
||||
albums(
|
||||
"""
|
||||
<subsonic-response status="ok">
|
||||
<albumList2>
|
||||
<album id="al1" name="The Wall">
|
||||
<discTitles title="No number"/>
|
||||
<discTitles disc="2" title="The Storm"/>
|
||||
</album>
|
||||
</albumList2>
|
||||
</subsonic-response>
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
assertEquals(mapOf(2L to "The Storm"), albums.single().discTitles)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectsExternalEntities() {
|
||||
val xml =
|
||||
"""
|
||||
<!DOCTYPE subsonic-response [<!ENTITY xxe SYSTEM "file:///etc/passwd">]>
|
||||
<subsonic-response status="ok"><artists><artist id="&xxe;" name="x"/></artists></subsonic-response>
|
||||
""".trimIndent()
|
||||
val failure =
|
||||
runCatching {
|
||||
SubsonicXml.readStatus(xml.byteInputStream(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||
}
|
||||
|
||||
private class CountingInputStream(
|
||||
input: InputStream,
|
||||
) : FilterInputStream(input) {
|
||||
var bytesRead = 0
|
||||
private set
|
||||
|
||||
override fun read(): Int =
|
||||
super.read().also {
|
||||
if (it >= 0) bytesRead++
|
||||
}
|
||||
|
||||
override fun read(
|
||||
b: ByteArray,
|
||||
off: Int,
|
||||
len: Int,
|
||||
): Int =
|
||||
super.read(b, off, len).also {
|
||||
if (it > 0) bytesRead += it
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room3.Room
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.prefs.fakeUserPreferences
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.OkHttpClient
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class SyncManagerTest {
|
||||
private lateinit var db: SubtracksDatabase
|
||||
private lateinit var sourceRepository: SourceRepository
|
||||
private lateinit var queueRepository: QueueRepository
|
||||
private lateinit var manager: SyncManager
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
val context = ApplicationProvider.getApplicationContext<Context>()
|
||||
db =
|
||||
Room
|
||||
.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java)
|
||||
.setDriver(BundledSQLiteDriver())
|
||||
.build()
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||
queueRepository = QueueRepository(db)
|
||||
manager = SyncManager(sourceRepository, queueRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
sourceRepository.close()
|
||||
db.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aFailedSyncStillInvalidatesTheLibraryCache() =
|
||||
runBlocking {
|
||||
val before = queueRepository.snapshot().version
|
||||
|
||||
manager.requestSync()
|
||||
val status = withTimeout(10_000) { manager.status.first { it is SyncStatus.Failed } }
|
||||
|
||||
assertTrue(status is SyncStatus.Failed)
|
||||
assertEquals(before + 1, queueRepository.snapshot().version)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
|
||||
import android.content.Context
|
||||
import androidx.paging.PagingSource
|
||||
import androidx.room3.Room
|
||||
import androidx.room3.useWriterConnection
|
||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
@@ -392,6 +393,167 @@ class SyncServiceTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistSongsAreFetchedOnlyForSurvivingPlaylists() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
songs = listOf(song("s1"), song("s2")),
|
||||
playlists = listOf(playlist("p1"), playlist("p2")),
|
||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0), PlaylistSong(1, "p2", "s2", 0)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
source.playlists = listOf(playlist("p1"))
|
||||
source.playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(listOf("p1"), source.requestedPlaylistIds.last())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun playlistEmptiedToZeroRemovesAllSongs() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
songs = listOf(song("s1"), song("s2")),
|
||||
playlists = listOf(playlist("p1")),
|
||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0), PlaylistSong(1, "p1", "s2", 1)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
assertEquals(
|
||||
2,
|
||||
db
|
||||
.libraryDao()
|
||||
.playlistSongs(1, "p1")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
|
||||
source.playlistSongs = emptyList()
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(1, db.libraryDao().playlistIds(1).size)
|
||||
assertEquals(
|
||||
0,
|
||||
db
|
||||
.libraryDao()
|
||||
.playlistSongs(1, "p1")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun discTitleSetShrinksForARetainedAlbum() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(albums = listOf(album("al1").copy(discTitles = mapOf(1L to "A", 2L to "B"))))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
assertEquals(mapOf(1L to "A", 2L to "B"), storedDiscs("al1"))
|
||||
|
||||
source.albums = listOf(album("al1").copy(discTitles = mapOf(1L to "A")))
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(mapOf(1L to "A"), storedDiscs("al1"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncingOneSourceDoesNotTouchAnother() =
|
||||
runTest {
|
||||
insertSource()
|
||||
insertSource(id = 2, name = "other")
|
||||
val sourceOne =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1"), artist("a2")),
|
||||
songs = listOf(song("s1")),
|
||||
playlists = listOf(playlist("p1")),
|
||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
|
||||
)
|
||||
val sourceTwo =
|
||||
FakeMusicSource(
|
||||
id = 2,
|
||||
artists = listOf(artist("b1").copy(sourceId = 2)),
|
||||
songs = listOf(song("s2").copy(sourceId = 2)),
|
||||
playlists = listOf(playlist("p2").copy(sourceId = 2)),
|
||||
playlistSongs = listOf(PlaylistSong(2, "p2", "s2", 0)),
|
||||
)
|
||||
SyncService(db, sourceOne).sync()
|
||||
SyncService(db, sourceTwo).sync()
|
||||
|
||||
sourceOne.artists = listOf(artist("a1"))
|
||||
sourceOne.songs = emptyList()
|
||||
sourceOne.playlists = emptyList()
|
||||
sourceOne.playlistSongs = emptyList()
|
||||
|
||||
SyncService(db, sourceOne).sync()
|
||||
|
||||
assertEquals(listOf("b1"), db.libraryDao().artistIds(2))
|
||||
assertEquals(listOf("s2"), db.libraryDao().songIds(2))
|
||||
assertEquals(listOf("p2"), db.libraryDao().playlistIds(2))
|
||||
assertEquals(
|
||||
1,
|
||||
db
|
||||
.libraryDao()
|
||||
.playlistSongs(2, "p2")
|
||||
.allRows()
|
||||
.size,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun prunesMoreThanOneDeleteChunk() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source = FakeMusicSource(artists = (1..1100).map { artist("a$it") })
|
||||
|
||||
SyncService(db, source).sync()
|
||||
assertEquals(1100, db.libraryDao().artistIds(1).size)
|
||||
|
||||
source.artists = emptyList()
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(0, db.libraryDao().artistIds(1).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun anIdenticalSyncDoesNotRewriteRows() =
|
||||
runTest {
|
||||
insertSource()
|
||||
val source =
|
||||
FakeMusicSource(
|
||||
artists = listOf(artist("a1")),
|
||||
albums = listOf(album("al1")),
|
||||
songs = listOf(song("s1")),
|
||||
playlists = listOf(playlist("p1")),
|
||||
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
|
||||
)
|
||||
|
||||
SyncService(db, source).sync()
|
||||
val before = totalChanges()
|
||||
|
||||
SyncService(db, source).sync()
|
||||
|
||||
assertEquals(before, totalChanges())
|
||||
}
|
||||
|
||||
private suspend fun totalChanges(): Long =
|
||||
db.useWriterConnection { connection ->
|
||||
connection.usePrepared("SELECT total_changes()") { statement ->
|
||||
statement.step()
|
||||
statement.getLong(0)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun storedDiscs(albumId: String): Map<Long, String> =
|
||||
db
|
||||
.libraryDao()
|
||||
@@ -404,9 +566,12 @@ class SyncServiceTest {
|
||||
return (page as PagingSource.LoadResult.Page).data
|
||||
}
|
||||
|
||||
private suspend fun insertSource() {
|
||||
private suspend fun insertSource(
|
||||
id: Long = 1,
|
||||
name: String = "test",
|
||||
) {
|
||||
db.sourcesDao().upsertSource(
|
||||
Source(id = 1, name = "test", address = "http://localhost", isActive = true, createdAt = 0),
|
||||
Source(id = id, name = name, address = "http://localhost/$id", isActive = id == 1L, createdAt = 0),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -453,6 +618,7 @@ class SyncServiceTest {
|
||||
}
|
||||
|
||||
private class FakeMusicSource(
|
||||
override val id: Long = 1,
|
||||
var artists: List<Artist> = emptyList(),
|
||||
var albums: List<Album> = emptyList(),
|
||||
var songs: List<Song> = emptyList(),
|
||||
@@ -460,7 +626,7 @@ private class FakeMusicSource(
|
||||
var playlistSongs: List<PlaylistSong> = emptyList(),
|
||||
private val batchSize: Int = 1000,
|
||||
) : MusicSource {
|
||||
override val id: Long = 1
|
||||
val requestedPlaylistIds = mutableListOf<List<String>>()
|
||||
|
||||
override suspend fun ping() = Unit
|
||||
|
||||
@@ -472,7 +638,10 @@ private class FakeMusicSource(
|
||||
|
||||
override fun playlists(): Flow<List<Playlist>> = batches(playlists)
|
||||
|
||||
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = batches(playlistSongs)
|
||||
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> {
|
||||
requestedPlaylistIds += playlistIds
|
||||
return batches(playlistSongs)
|
||||
}
|
||||
|
||||
private fun <T> batches(items: List<T>): Flow<List<T>> =
|
||||
flow {
|
||||
|
||||
Reference in New Issue
Block a user