From be6bcd11dcbbe1b44c005c28f7c14e2017acaf86 Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Fri, 25 Sep 2026 09:31:25 +0000 Subject: [PATCH] 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. --- .../subtracks/data/repo/SourceRepository.kt | 11 +- .../data/source/subsonic/SubsonicXml.kt | 25 +-- .../com/subtracks/data/sync/SyncService.kt | 20 +- .../source/subsonic/SubsonicSourceTest.kt | 117 ++++++++++++ .../data/source/subsonic/SubsonicXmlTest.kt | 145 ++++++++++++++ .../subtracks/data/sync/SyncManagerTest.kt | 60 ++++++ .../subtracks/data/sync/SyncServiceTest.kt | 177 +++++++++++++++++- 7 files changed, 523 insertions(+), 32 deletions(-) create mode 100644 app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt diff --git a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt index 68609af6..9f9441c3 100644 --- a/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt +++ b/app/src/main/kotlin/com/subtracks/data/repo/SourceRepository.kt @@ -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 = - 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( diff --git a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt index 15e4b4a2..fb5c740b 100644 --- a/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt +++ b/app/src/main/kotlin/com/subtracks/data/source/subsonic/SubsonicXml.kt @@ -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 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( 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") } } diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt index 2eef267b..a3dea53e 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncService.kt @@ -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, values: (T) -> List, ) { - if (rows.isEmpty()) return db.useWriterConnection { connection -> connection.immediateTransaction { connection.upsertChanged(table, columns, key, rows, values) diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt index e59120c5..14405cf4 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicSourceTest.kt @@ -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() + 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() + 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( + "" + + "" + + "" + + "", + ), + ) + + val artists = SubsonicSource(1, client()).artists().toList().flatten() + + assertEquals(listOf("ar1"), artists.map { it.id }) + } + + @Test + fun cancellingAfterTheFirstBatchDoesNotHang() = + runBlocking { + val body = + buildString { + append("") + repeat(100_000) { append("") } + append("") + } + 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, ) = "" + private fun emptyAlbumList() = "" + + private fun albumPage( + offset: Int, + count: Int, + ) = "" + + (offset until offset + count).joinToString("") { + "" + } + + "" + + private fun songPage( + prefix: String, + offset: Int, + count: Int, + ) = "" + + (offset until offset + count).joinToString("") { + "" + } + + "" + private companion object { const val ALBUM_LIST = "" + diff --git a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt index 2a223323..7e021654 100644 --- a/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/source/subsonic/SubsonicXmlTest.kt @@ -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 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("") + repeat(5_000) { append("") } + append("") + } + 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("".byteInputStream(Charsets.UTF_8)) + } + + assertEquals(true, failure.exceptionOrNull() is SubsonicException) + } + + @Test + fun failedStatusWithoutAnErrorStillThrows() { + val failure = + runCatching { + SubsonicXml.readStatus( + "".byteInputStream(Charsets.UTF_8), + ) + } + + assertEquals(true, failure.exceptionOrNull() is SubsonicException) + } + + @Test + fun errorElementWithoutStatusThrowsWithItsCode() { + val error = + runCatching { + SubsonicXml.readStatus( + "" + .byteInputStream(Charsets.UTF_8), + ) + }.exceptionOrNull() as? SubsonicException + + assertEquals(40, error?.code) + assertEquals("Wrong username", error?.message) + } + + @Test + fun nestedSameNameElementsEmitOnce() { + val albums = + albums( + """ + + + + + + + + """.trimIndent(), + ) + + assertEquals(listOf("al1"), albums.map { it.id }) + } + + @Test + fun ignoresDiscTitlesThatAreNotDirectChildren() { + val albums = + albums( + """ + + + + + + + + """.trimIndent(), + ) + + assertEquals(emptyMap(), albums.single().discTitles) + } + + @Test + fun discTitleWithoutADiscNumberIsIgnored() { + val albums = + albums( + """ + + + + + + + + + """.trimIndent(), + ) + + assertEquals(mapOf(2L to "The Storm"), albums.single().discTitles) + } + + @Test + fun rejectsExternalEntities() { + val xml = + """ + ]> + + """.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 + } + } } diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt new file mode 100644 index 00000000..83424c51 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncManagerTest.kt @@ -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() + 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) + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt index 8e3f11d2..ed6ca389 100644 --- a/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt +++ b/app/src/test/kotlin/com/subtracks/data/sync/SyncServiceTest.kt @@ -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 = 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 = emptyList(), var albums: List = emptyList(), var songs: List = emptyList(), @@ -460,7 +626,7 @@ private class FakeMusicSource( var playlistSongs: List = emptyList(), private val batchSize: Int = 1000, ) : MusicSource { - override val id: Long = 1 + val requestedPlaylistIds = mutableListOf>() override suspend fun ping() = Unit @@ -472,7 +638,10 @@ private class FakeMusicSource( override fun playlists(): Flow> = batches(playlists) - override fun playlistSongs(playlistIds: List): Flow> = batches(playlistSongs) + override fun playlistSongs(playlistIds: List): Flow> { + requestedPlaylistIds += playlistIds + return batches(playlistSongs) + } private fun batches(items: List): Flow> = flow {