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.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)
|
||||
|
||||
Reference in New Issue
Block a user