mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +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.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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user