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:
austinried
2026-10-04 00:29:58 +00:00
parent b171b0b33c
commit be6bcd11dc
7 changed files with 523 additions and 32 deletions
@@ -16,6 +16,8 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -27,6 +29,7 @@ class SourceRepository(
private val prefs: UserPreferences, private val prefs: UserPreferences,
) { ) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val syncLock = Mutex()
@Volatile @Volatile
private var active: SubsonicMusicSource? = null private var active: SubsonicMusicSource? = null
@@ -116,9 +119,11 @@ class SourceRepository(
} }
suspend fun sync(): Result<Unit> = suspend fun sync(): Result<Unit> =
runCatching { syncLock.withLock {
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured") runCatching {
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync() val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
}
} }
private fun SubsonicConfig.toMusicSource( private fun SubsonicConfig.toMusicSource(
@@ -8,6 +8,7 @@ import com.subtracks.data.model.Song
import org.xml.sax.Attributes import org.xml.sax.Attributes
import org.xml.sax.SAXException import org.xml.sax.SAXException
import org.xml.sax.helpers.DefaultHandler import org.xml.sax.helpers.DefaultHandler
import java.io.IOException
import java.io.InputStream import java.io.InputStream
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
@@ -17,7 +18,7 @@ import javax.xml.parsers.ParserConfigurationException
import javax.xml.parsers.SAXParserFactory import javax.xml.parsers.SAXParserFactory
object SubsonicXml { object SubsonicXml {
fun artist( internal fun artist(
sourceId: Long, sourceId: Long,
attrs: Attributes, attrs: Attributes,
): Artist = ): Artist =
@@ -48,7 +49,7 @@ object SubsonicXml {
songCount = attrs.longAttr("songCount") ?: 0L, songCount = attrs.longAttr("songCount") ?: 0L,
) )
fun song( internal fun song(
sourceId: Long, sourceId: Long,
attrs: Attributes, attrs: Attributes,
): Song = ): Song =
@@ -68,7 +69,7 @@ object SubsonicXml {
created = attrs.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
) )
fun playlist( internal fun playlist(
sourceId: Long, sourceId: Long,
attrs: Attributes, attrs: Attributes,
): Playlist = ): Playlist =
@@ -84,7 +85,7 @@ object SubsonicXml {
duration = attrs.longAttr("duration") ?: 0L, duration = attrs.longAttr("duration") ?: 0L,
) )
fun playlistSong( internal fun playlistSong(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
position: Long, position: Long,
@@ -98,7 +99,7 @@ object SubsonicXml {
) )
internal fun readStatus(input: InputStream) { internal fun readStatus(input: InputStream) {
parse(input, StatusHandler()) parse(input, SubsonicResponseHandler())
} }
internal fun <T> readEntities( internal fun <T> readEntities(
@@ -175,9 +176,8 @@ private class XmlFailure(
private class StopReading : RuntimeException() private class StopReading : RuntimeException()
private abstract class SubsonicResponseHandler : DefaultHandler() { private open class SubsonicResponseHandler : DefaultHandler() {
private var depth = 0 private var depth = 0
private var sawRoot = false
private var statusFailed = false private var statusFailed = false
override fun startElement( override fun startElement(
@@ -187,10 +187,9 @@ private abstract class SubsonicResponseHandler : DefaultHandler() {
attributes: Attributes, attributes: Attributes,
) { ) {
depth++ depth++
if (!sawRoot) { if (depth == 1) {
sawRoot = true
if (qName != ROOT_TAG) throw XmlFailure(-1, "Unexpected response from the server") 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") { if (qName == "error") {
throw XmlFailure( throw XmlFailure(
@@ -226,8 +225,6 @@ private abstract class SubsonicResponseHandler : DefaultHandler() {
) = Unit ) = Unit
} }
private class StatusHandler : SubsonicResponseHandler()
private class EntityHandler<T>( private class EntityHandler<T>(
private val entityTag: String, private val entityTag: String,
private val create: (Attributes) -> T, private val create: (Attributes) -> T,
@@ -281,6 +278,10 @@ private fun parse(
} catch (failure: XmlFailure) { } catch (failure: XmlFailure) {
throw SubsonicException(failure.code, failure.message ?: "Unknown error") throw SubsonicException(failure.code, failure.message ?: "Unknown error")
} catch (_: StopReading) { } 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.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
class SyncService( class SyncService(
private val db: SubtracksDatabase, private val db: SubtracksDatabase,
private val source: MusicSource, private val source: MusicSource,
) { ) {
private val lock = Mutex() suspend fun sync() {
syncArtists()
suspend fun sync() = syncAlbums()
lock.withLock { syncSongs()
syncArtists() syncPlaylists()
syncAlbums() syncPlaylistSongs()
syncSongs() }
syncPlaylists()
syncPlaylistSongs()
}
private suspend fun syncArtists() { private suspend fun syncArtists() {
val library = db.libraryDao() val library = db.libraryDao()
@@ -102,7 +97,6 @@ class SyncService(
rows: List<T>, rows: List<T>,
values: (T) -> List<Any?>, values: (T) -> List<Any?>,
) { ) {
if (rows.isEmpty()) return
db.useWriterConnection { connection -> db.useWriterConnection { connection ->
connection.immediateTransaction { connection.immediateTransaction {
connection.upsertChanged(table, columns, key, rows, values) connection.upsertChanged(table, columns, key, rows, values)
@@ -1,7 +1,9 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.toList import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withTimeout
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.Dispatcher
import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockResponse
@@ -13,6 +15,8 @@ import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before import org.junit.Before
import org.junit.Test import org.junit.Test
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.atomic.AtomicInteger
class SubsonicSourceTest { class SubsonicSourceTest {
private lateinit var server: MockWebServer private lateinit var server: MockWebServer
@@ -83,6 +87,98 @@ class SubsonicSourceTest {
assertEquals(listOf(500, 500, 200), sizes) 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 @Test
fun streamUriRequestsTranscodingWhenABitrateIsSet() { fun streamUriRequestsTranscodingWhenABitrateIsSet() {
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString() val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
@@ -122,6 +218,27 @@ class SubsonicSourceTest {
message: String, message: String,
) = "<subsonic-response status=\"failed\"><error code=\"$code\" message=\"$message\"/></subsonic-response>" ) = "<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 { private companion object {
const val ALBUM_LIST = const val ALBUM_LIST =
"<subsonic-response status=\"ok\" version=\"1.16.1\"><albumList2>" + "<subsonic-response status=\"ok\" version=\"1.16.1\"><albumList2>" +
@@ -1,8 +1,11 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import org.xml.sax.Attributes import org.xml.sax.Attributes
import java.io.FilterInputStream
import java.io.InputStream
class SubsonicXmlTest { class SubsonicXmlTest {
private fun <T> parse( private fun <T> parse(
@@ -156,4 +159,146 @@ class SubsonicXmlTest {
assertEquals(true, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "song")) assertEquals(true, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "song"))
assertEquals(false, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "artist")) 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 android.content.Context
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.room3.Room import androidx.room3.Room
import androidx.room3.useWriterConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 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> = private suspend fun storedDiscs(albumId: String): Map<Long, String> =
db db
.libraryDao() .libraryDao()
@@ -404,9 +566,12 @@ class SyncServiceTest {
return (page as PagingSource.LoadResult.Page).data return (page as PagingSource.LoadResult.Page).data
} }
private suspend fun insertSource() { private suspend fun insertSource(
id: Long = 1,
name: String = "test",
) {
db.sourcesDao().upsertSource( 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( private class FakeMusicSource(
override val id: Long = 1,
var artists: List<Artist> = emptyList(), var artists: List<Artist> = emptyList(),
var albums: List<Album> = emptyList(), var albums: List<Album> = emptyList(),
var songs: List<Song> = emptyList(), var songs: List<Song> = emptyList(),
@@ -460,7 +626,7 @@ private class FakeMusicSource(
var playlistSongs: List<PlaylistSong> = emptyList(), var playlistSongs: List<PlaylistSong> = emptyList(),
private val batchSize: Int = 1000, private val batchSize: Int = 1000,
) : MusicSource { ) : MusicSource {
override val id: Long = 1 val requestedPlaylistIds = mutableListOf<List<String>>()
override suspend fun ping() = Unit override suspend fun ping() = Unit
@@ -472,7 +638,10 @@ private class FakeMusicSource(
override fun playlists(): Flow<List<Playlist>> = batches(playlists) 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>> = private fun <T> batches(items: List<T>): Flow<List<T>> =
flow { flow {