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.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)