stream sync with SAX and write the library per batch

The DOM-based sync materialised the whole library in memory before
writing: SubsonicSource accumulated every paged response into one list
and SubsonicClient built a full DOM per response, so peak memory grew
with the library size (hundreds of MB for large libraries).

Replace it with a streaming pipeline:

- SubsonicXml is now a SAX reader that maps entities straight from the
  stream. Album disc titles are accumulated from child elements and
  attached when the album closes.
- SubsonicClient exposes stream()/check() instead of returning a DOM.
- MusicSource returns batches as Flow<List<T>>; SubsonicSource emits one
  batch per response page with backpressure so only one batch is held.
- SyncService syncs each entity sequentially, commits one transaction
  per batch, and prunes with an existing-minus-seen stale set. Playlist
  song tails are pruned once the whole flow has been counted, and
  orphaned playlist_songs rows are removed with a single delete.

Sync is no longer atomic: on failure the committed batches remain and
the queue's library cache is now invalidated on failure too.
This commit is contained in:
austinried
2026-10-04 00:29:58 +00:00
parent bce074ecdc
commit b171b0b33c
13 changed files with 804 additions and 331 deletions
@@ -1,5 +1,7 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
@@ -40,7 +42,7 @@ class SubsonicSourceIntegrationTest(
@Test @Test
fun artists() = fun artists() =
runBlocking { runBlocking {
val artists = source.getArtists() val artists = source.artists().collectAll()
assertEquals(2, artists.size) assertEquals(2, artists.size)
assertNotNull(artists.first { it.name == "Ugress" }.starred) assertNotNull(artists.first { it.name == "Ugress" }.starred)
@@ -50,7 +52,7 @@ class SubsonicSourceIntegrationTest(
@Test @Test
fun albums() = fun albums() =
runBlocking { runBlocking {
val albums = source.getAlbums() val albums = source.albums().collectAll()
assertEquals(3, albums.size) assertEquals(3, albums.size)
@@ -72,23 +74,23 @@ class SubsonicSourceIntegrationTest(
@Test @Test
fun songs() = fun songs() =
runBlocking { runBlocking {
assertEquals(20, source.getSongs().size) assertEquals(20, source.songs().collectAll().size)
} }
@Test @Test
fun playlists() = fun playlists() =
runBlocking { runBlocking {
val playlists = source.getPlaylists() val playlists = source.playlists().collectAll()
assertEquals(1, playlists.size) assertEquals(1, playlists.size)
assertEquals(7, source.getPlaylistSongs(playlists).size) assertEquals(7, source.playlistSongs(playlists.map { it.id }).collectAll().size)
} }
@Test @Test
fun albumArtistRelation() = fun albumArtistRelation() =
runBlocking { runBlocking {
val artists = source.getArtists() val artists = source.artists().collectAll()
val albums = source.getAlbums() val albums = source.albums().collectAll()
val ugressAlbums = val ugressAlbums =
albums albums
@@ -104,6 +106,8 @@ class SubsonicSourceIntegrationTest(
assertEquals(listOf("I Don't Know What I'm Doing"), bradAlbums) assertEquals(listOf("I Don't Know What I'm Doing"), bradAlbums)
} }
private suspend fun <T> Flow<List<T>>.collectAll(): List<T> = toList().flatten()
data class Server( data class Server(
val name: String, val name: String,
val baseUrl: String, val baseUrl: String,
@@ -115,6 +115,12 @@ interface LibraryDao {
position: Long, position: Long,
) )
@Query(
"DELETE FROM playlist_songs WHERE sourceId = :sourceId " +
"AND playlistId NOT IN (SELECT id FROM playlists WHERE sourceId = :sourceId)",
)
suspend fun deleteOrphanPlaylistSongs(sourceId: Long)
@Query( @Query(
"SELECT * FROM albums WHERE sourceId = :sourceId " + "SELECT * FROM albums WHERE sourceId = :sourceId " +
"AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " + "AND (:starredFilter = 0 OR (:starredFilter = 1 AND starred IS NOT NULL) OR (:starredFilter = 2 AND starred IS NULL)) " +
@@ -112,7 +112,7 @@ class SourceRepository(
useTokenAuth: Boolean, useTokenAuth: Boolean,
): Result<Unit> = ): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
runCatching { client(address, username, password, useTokenAuth).get("ping") }.map { } runCatching { client(address, username, password, useTokenAuth).check("ping") }
} }
suspend fun sync(): Result<Unit> = suspend fun sync(): Result<Unit> =
@@ -5,19 +5,20 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist 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 kotlinx.coroutines.flow.Flow
interface MusicSource { interface MusicSource {
val id: Long val id: Long
suspend fun ping() suspend fun ping()
suspend fun getArtists(): List<Artist> fun artists(): Flow<List<Artist>>
suspend fun getAlbums(): List<Album> fun albums(): Flow<List<Album>>
suspend fun getSongs(): List<Song> fun songs(): Flow<List<Song>>
suspend fun getPlaylists(): List<Playlist> fun playlists(): Flow<List<Playlist>>
suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>>
} }
@@ -3,11 +3,8 @@ package com.subtracks.data.source.subsonic
import okhttp3.HttpUrl import okhttp3.HttpUrl
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
import org.w3c.dom.Document import java.io.InputStream
import org.w3c.dom.Element
import java.security.MessageDigest import java.security.MessageDigest
import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.parsers.ParserConfigurationException
class SubsonicException( class SubsonicException(
val code: Int, val code: Int,
@@ -41,10 +38,18 @@ class SubsonicClient(
return builder.build() return builder.build()
} }
fun get( fun check(
method: String, method: String,
params: Map<String, String> = emptyMap(), params: Map<String, String> = emptyMap(),
): Document { ) {
stream(method, params) { SubsonicXml.readStatus(it) }
}
fun <T> stream(
method: String,
params: Map<String, String> = emptyMap(),
body: (InputStream) -> T,
): T {
val request = val request =
Request Request
.Builder() .Builder()
@@ -55,20 +60,7 @@ class SubsonicClient(
if (!response.isSuccessful) { if (!response.isSuccessful) {
throw SubsonicException(-1, "HTTP ${response.code}") throw SubsonicException(-1, "HTTP ${response.code}")
} }
val stream = response.body.byteStream() return body(response.body.byteStream())
val document = secureDocumentBuilderFactory().newDocumentBuilder().parse(stream)
val root = document.documentElement
if (root == null || root.tagName != "subsonic-response") {
throw SubsonicException(-1, "Unexpected response from the server")
}
if (root.getAttribute("status") == "failed") {
val error = root.getElementsByTagName("error").item(0) as? Element
throw SubsonicException(
error?.getAttribute("code")?.toIntOrNull() ?: -1,
error?.getAttribute("message") ?: "Unknown error",
)
}
return document
} }
} }
@@ -76,27 +68,6 @@ class SubsonicClient(
const val API_VERSION = "1.13.0" const val API_VERSION = "1.13.0"
const val CLIENT = "subtracks" const val CLIENT = "subtracks"
private fun secureDocumentBuilderFactory(): DocumentBuilderFactory =
DocumentBuilderFactory.newInstance().apply {
isNamespaceAware = false
isExpandEntityReferences = false
setFeatureQuietly("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeatureQuietly("http://xml.org/sax/features/external-general-entities", false)
setFeatureQuietly("http://xml.org/sax/features/external-parameter-entities", false)
setFeatureQuietly("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
}
private fun DocumentBuilderFactory.setFeatureQuietly(
name: String,
enabled: Boolean,
) {
try {
setFeature(name, enabled)
} catch (_: ParserConfigurationException) {
// Android's parser does not implement every hardening flag; the root-tag check in get() is the backstop.
}
}
private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("") private fun randomSalt(): String = (1..4).map { ('a'..'z').random() }.joinToString("")
private fun md5Hex(input: String): String = private fun md5Hex(input: String): String =
@@ -6,9 +6,20 @@ 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.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.trySendBlocking
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.xml.sax.Attributes
@OptIn(ExperimentalCoroutinesApi::class)
class SubsonicSource( class SubsonicSource(
override val id: Long, override val id: Long,
private val client: SubsonicClient, private val client: SubsonicClient,
@@ -18,53 +29,77 @@ class SubsonicSource(
private var emptyQuerySearchSupported: Boolean? = null private var emptyQuerySearchSupported: Boolean? = null
override suspend fun ping() { override suspend fun ping() {
withContext(Dispatchers.IO) { client.get("ping") } withContext(Dispatchers.IO) { client.check("ping") }
} }
override suspend fun getArtists(): List<Artist> = override fun artists(): Flow<List<Artist>> =
withContext(Dispatchers.IO) { entityBatches(
SubsonicXml.artists(id, client.get("getArtists")) method = "getArtists",
} tag = "artist",
create = { SubsonicXml.artist(id, it) },
accept = { it.id.isNotEmpty() },
)
override suspend fun getAlbums(): List<Album> = override fun albums(): Flow<List<Album>> =
withContext(Dispatchers.IO) { flow {
val (frequent, recent) = fetchRanks() val (frequent, recent) = fetchRanks()
val albums = mutableListOf<Album>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < MAX_PAGES) { while (pages < MAX_PAGES) {
val document = client.get("getAlbumList2", page("newest", offset)) var count = 0
val batch = SubsonicXml.albums(id, document) entityBatches(
albums += method = "getAlbumList2",
params = page("newest", offset),
tag = "album",
create = { SubsonicXml.albumDraft(id, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
accept = { it.id.isNotEmpty() },
).collect { batch ->
count += batch.size
emit(
batch.map { batch.map {
it.copy(frequentRank = frequent[it.id], recentRank = recent[it.id]) it.toAlbum().copy(
frequentRank = frequent[it.id],
recentRank = recent[it.id],
)
},
)
} }
if (batch.size < PAGE_SIZE) break if (count < PAGE_SIZE) break
offset += PAGE_SIZE offset += PAGE_SIZE
pages++ pages++
} }
albums }.flowOn(Dispatchers.IO)
}
override suspend fun getSongs(): List<Song> = override fun songs(): Flow<List<Song>> =
withContext(Dispatchers.IO) { flow {
if (supportsEmptyQuerySearch()) searchSongs() else albumSongs() if (supportsEmptyQuerySearch()) {
emitAll(searchSongs())
} else {
emitAll(albumSongs())
} }
}.flowOn(Dispatchers.IO)
override suspend fun getPlaylists(): List<Playlist> = override fun playlists(): Flow<List<Playlist>> =
withContext(Dispatchers.IO) { entityBatches(
SubsonicXml.playlists(id, client.get("getPlaylists")) method = "getPlaylists",
} tag = "playlist",
create = { SubsonicXml.playlist(id, it) },
accept = { it.id.isNotEmpty() },
)
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> = override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> =
withContext(Dispatchers.IO) { flow {
val result = mutableListOf<PlaylistSong>() for (playlistId in playlistIds) {
for (playlist in playlists) { var position = 0L
val document = client.get("getPlaylist", mapOf("id" to playlist.id)) entityBatches(
result += SubsonicXml.playlistSongs(id, playlist.id, document) method = "getPlaylist",
} params = mapOf("id" to playlistId),
result tag = "entry",
create = { attrs -> SubsonicXml.playlistSong(id, playlistId, position++, attrs) },
).collect { emit(it) }
} }
}.flowOn(Dispatchers.IO)
fun streamUri(songId: String) = fun streamUri(songId: String) =
client.uri( client.uri(
@@ -93,16 +128,84 @@ class SubsonicSource(
) )
} }
private fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent") private fun searchSongs(): Flow<List<Song>> =
flow {
private fun fetchRank(type: String): Map<String, Long> {
val ranks = mutableMapOf<String, Long>()
var offset = 0 var offset = 0
var pages = 0 var pages = 0
while (pages < MAX_PAGES) { while (pages < MAX_PAGES) {
val batch = SubsonicXml.albums(id, client.get("getAlbumList2", page(type, offset))) var count = 0
batch.forEachIndexed { index, album -> ranks[album.id] = (offset + index).toLong() } entityBatches(
if (batch.size < PAGE_SIZE) break method = "search3",
params =
mapOf(
"query" to "\"\"",
"songCount" to PAGE_SIZE.toString(),
"songOffset" to offset.toString(),
"artistCount" to "0",
"albumCount" to "0",
),
tag = "song",
create = { SubsonicXml.song(id, it) },
accept = { it.id.isNotEmpty() },
).collect { batch ->
count += batch.size
emit(batch)
}
if (count < PAGE_SIZE) break
offset += PAGE_SIZE
pages++
}
}
private fun albumSongs(): Flow<List<Song>> =
flow {
var offset = 0
var pages = 0
while (pages < MAX_PAGES) {
val albums = ArrayList<String>(PAGE_SIZE)
entityBatches(
method = "getAlbumList2",
params = page("alphabeticalByName", offset),
tag = "album",
create = { it.attr("id") },
accept = { it.isNotEmpty() },
).collect { albums += it }
for (albumId in albums) {
entityBatches(
method = "getAlbum",
params = mapOf("id" to albumId),
tag = "song",
create = { SubsonicXml.song(id, it) },
accept = { it.id.isNotEmpty() },
).collect { emit(it) }
}
if (albums.size < PAGE_SIZE) break
offset += PAGE_SIZE
pages++
}
}
private suspend fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
private suspend fun fetchRank(type: String): Map<String, Long> {
val ranks = HashMap<String, Long>()
var offset = 0
var pages = 0
while (pages < MAX_PAGES) {
var index = 0
entityBatches(
method = "getAlbumList2",
params = page(type, offset),
tag = "album",
create = { it.attr("id") },
accept = { it.isNotEmpty() },
).collect { batch ->
batch.forEach { albumId ->
ranks[albumId] = (offset + index).toLong()
index++
}
}
if (index < PAGE_SIZE) break
offset += PAGE_SIZE offset += PAGE_SIZE
pages++ pages++
} }
@@ -113,12 +216,10 @@ class SubsonicSource(
emptyQuerySearchSupported?.let { return it } emptyQuerySearchSupported?.let { return it }
val supported = val supported =
try { try {
val document = client.stream(
client.get(
"search3", "search3",
mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"), mapOf("query" to "\"\"", "songCount" to "1", "artistCount" to "0", "albumCount" to "0"),
) ) { input -> SubsonicXml.containsEntity(input, "song") }
document.getElementsByTagName("song").length > 0
} catch (_: SubsonicException) { } catch (_: SubsonicException) {
false false
} }
@@ -126,48 +227,41 @@ class SubsonicSource(
return supported return supported
} }
private fun searchSongs(): List<Song> { private fun <T> entityBatches(
val songs = mutableListOf<Song>() method: String,
var offset = 0 params: Map<String, String> = emptyMap(),
var pages = 0 tag: String,
while (pages < MAX_PAGES) { create: (Attributes) -> T,
val document = onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
client.get( accept: (T) -> Boolean = { true },
"search3", ): Flow<List<T>> =
mapOf( channelFlow {
"query" to "\"\"", val buffer = ArrayList<T>(PAGE_SIZE)
"songCount" to PAGE_SIZE.toString(),
"songOffset" to offset.toString(), fun flush() {
"artistCount" to "0", if (buffer.isEmpty()) return
"albumCount" to "0", if (trySendBlocking(buffer.toList()).isFailure) {
), throw CancellationException("Library sync cancelled")
)
val batch = SubsonicXml.songs(id, document)
songs += batch
if (batch.size < PAGE_SIZE) break
offset += PAGE_SIZE
pages++
} }
return songs buffer.clear()
} }
private fun albumSongs(): List<Song> { client.stream(method, params) { input ->
val songs = mutableListOf<Song>() SubsonicXml.readEntities(
var offset = 0 input = input,
var pages = 0 entityTag = tag,
while (pages < MAX_PAGES) { create = create,
val document = client.get("getAlbumList2", page("alphabeticalByName", offset)) onChild = onChild,
val albums = SubsonicXml.albums(id, document) onEnd = { entity ->
for (album in albums) { if (accept(entity)) {
val albumDocument = client.get("getAlbum", mapOf("id" to album.id)) buffer += entity
songs += SubsonicXml.songs(id, albumDocument) if (buffer.size >= PAGE_SIZE) flush()
} }
if (albums.size < PAGE_SIZE) break },
offset += PAGE_SIZE )
pages++
}
return songs
} }
flush()
}.flowOn(Dispatchers.IO).buffer(1)
private fun page( private fun page(
type: String, type: String,
@@ -5,147 +5,305 @@ import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist 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 org.w3c.dom.Document import org.xml.sax.Attributes
import org.w3c.dom.Element import org.xml.sax.SAXException
import org.xml.sax.helpers.DefaultHandler
import java.io.InputStream
import java.time.LocalDate import java.time.LocalDate
import java.time.OffsetDateTime import java.time.OffsetDateTime
import java.time.ZoneOffset import java.time.ZoneOffset
import java.time.format.DateTimeParseException import java.time.format.DateTimeParseException
import javax.xml.parsers.ParserConfigurationException
import javax.xml.parsers.SAXParserFactory
object SubsonicXml { object SubsonicXml {
fun artists( fun artist(
sourceId: Long, sourceId: Long,
document: Document, attrs: Attributes,
): List<Artist> = ): Artist =
document
.elements("artist")
.map { element ->
Artist( Artist(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = attrs.attr("id"),
name = element.attr("name"), name = attrs.attr("name"),
albumCount = element.longAttr("albumCount") ?: 0L, albumCount = attrs.longAttr("albumCount") ?: 0L,
starred = element.dateAttr("starred"), starred = attrs.dateAttr("starred"),
coverArt = element.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
) )
}.filter { it.id.isNotEmpty() }
fun albums( internal fun albumDraft(
sourceId: Long, sourceId: Long,
document: Document, attrs: Attributes,
): List<Album> = document.elements("album").map { album(sourceId, it) }.filter { it.id.isNotEmpty() } ): AlbumDraft =
AlbumDraft(
fun album(
sourceId: Long,
element: Element,
): Album =
Album(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = attrs.attr("id"),
artistId = element.attr("artistId").ifEmpty { null }, artistId = attrs.attr("artistId").ifEmpty { null },
name = element.attr("name"), name = attrs.attr("name"),
albumArtist = element.attr("artist").ifEmpty { null }, albumArtist = attrs.attr("artist").ifEmpty { null },
created = element.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
coverArt = element.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
genre = element.attr("genre").ifEmpty { null }, genre = attrs.attr("genre").ifEmpty { null },
year = element.longAttr("year"), year = attrs.longAttr("year"),
starred = element.dateAttr("starred"), starred = attrs.dateAttr("starred"),
songCount = element.longAttr("songCount") ?: 0L, songCount = attrs.longAttr("songCount") ?: 0L,
frequentRank = null,
recentRank = null,
discTitles = element.discTitles(),
) )
fun songs(
sourceId: Long,
document: Document,
): List<Song> = document.elements("song").map { song(sourceId, it) }.filter { it.id.isNotEmpty() }
fun song( fun song(
sourceId: Long, sourceId: Long,
element: Element, attrs: Attributes,
): Song = ): Song =
Song( Song(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = attrs.attr("id"),
albumId = element.attr("albumId").ifEmpty { null }, albumId = attrs.attr("albumId").ifEmpty { null },
artistId = element.attr("artistId").ifEmpty { null }, artistId = attrs.attr("artistId").ifEmpty { null },
title = element.attr("title"), title = attrs.attr("title"),
album = element.attr("album").ifEmpty { null }, album = attrs.attr("album").ifEmpty { null },
artist = element.attr("artist").ifEmpty { null }, artist = attrs.attr("artist").ifEmpty { null },
duration = element.longAttr("duration"), duration = attrs.longAttr("duration"),
track = element.longAttr("track"), track = attrs.longAttr("track"),
disc = element.longAttr("discNumber"), disc = attrs.longAttr("discNumber"),
starred = element.dateAttr("starred"), starred = attrs.dateAttr("starred"),
genre = element.attr("genre").ifEmpty { null }, genre = attrs.attr("genre").ifEmpty { null },
created = element.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
) )
fun playlists(
sourceId: Long,
document: Document,
): List<Playlist> = document.elements("playlist").map { playlist(sourceId, it) }.filter { it.id.isNotEmpty() }
fun playlist( fun playlist(
sourceId: Long, sourceId: Long,
element: Element, attrs: Attributes,
): Playlist = ): Playlist =
Playlist( Playlist(
sourceId = sourceId, sourceId = sourceId,
id = element.attr("id"), id = attrs.attr("id"),
name = element.attr("name"), name = attrs.attr("name"),
comment = element.attr("comment").ifEmpty { null }, comment = attrs.attr("comment").ifEmpty { null },
coverArt = element.attr("coverArt").ifEmpty { null }, coverArt = attrs.attr("coverArt").ifEmpty { null },
songCount = element.longAttr("songCount") ?: 0L, songCount = attrs.longAttr("songCount") ?: 0L,
created = element.dateAttr("created") ?: 0L, created = attrs.dateAttr("created") ?: 0L,
changed = element.dateAttr("changed") ?: 0L, changed = attrs.dateAttr("changed") ?: 0L,
duration = element.longAttr("duration") ?: 0L, duration = attrs.longAttr("duration") ?: 0L,
) )
fun playlistSongs( fun playlistSong(
sourceId: Long, sourceId: Long,
playlistId: String, playlistId: String,
document: Document, position: Long,
): List<PlaylistSong> = attrs: Attributes,
document.elements("entry").mapIndexed { index, element -> ): PlaylistSong =
PlaylistSong( PlaylistSong(
sourceId = sourceId, sourceId = sourceId,
playlistId = playlistId, playlistId = playlistId,
songId = element.attr("id"), songId = attrs.attr("id"),
position = index.toLong(), position = position,
)
internal fun readStatus(input: InputStream) {
parse(input, StatusHandler())
}
internal fun <T> readEntities(
input: InputStream,
entityTag: String,
create: (Attributes) -> T,
onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
onEnd: (T) -> Unit,
) {
parse(input, EntityHandler(entityTag, create, onChild, onEnd, stopAfterFirst = false))
}
internal fun containsEntity(
input: InputStream,
entityTag: String,
): Boolean {
val handler = EntityHandler(entityTag, { Unit }, { _, _, _ -> }, { }, stopAfterFirst = true)
parse(input, handler)
return handler.found
}
}
internal class AlbumDraft(
val sourceId: Long,
var id: String = "",
var artistId: String? = null,
var name: String = "",
var albumArtist: String? = null,
var created: Long = 0L,
var coverArt: String? = null,
var genre: String? = null,
var year: Long? = null,
var starred: Long? = null,
var songCount: Long = 0L,
val discTitles: MutableMap<Long, String> = mutableMapOf(),
) {
fun addDiscTitle(attrs: Attributes) {
val disc = attrs.longAttr("disc") ?: return
val title = attrs.attr("title").takeIf { it.isNotBlank() } ?: return
discTitles[disc] = title
}
fun toAlbum(): Album =
Album(
sourceId = sourceId,
id = id,
artistId = artistId,
name = name,
albumArtist = albumArtist,
created = created,
coverArt = coverArt,
genre = genre,
year = year,
starred = starred,
songCount = songCount,
frequentRank = null,
recentRank = null,
discTitles = discTitles.toMap(),
) )
} }
internal fun Attributes.attr(name: String): String = getValue(name) ?: ""
internal fun Attributes.longAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
internal fun Attributes.dateAttr(name: String): Long? = attr(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
private const val ROOT_TAG = "subsonic-response"
private class XmlFailure(
val code: Int,
message: String,
) : RuntimeException(message)
private class StopReading : RuntimeException()
private abstract class SubsonicResponseHandler : DefaultHandler() {
private var depth = 0
private var sawRoot = false
private var statusFailed = false
override fun startElement(
uri: String?,
localName: String?,
qName: String,
attributes: Attributes,
) {
depth++
if (!sawRoot) {
sawRoot = true
if (qName != ROOT_TAG) throw XmlFailure(-1, "Unexpected response from the server")
if (attributes.getValue("status") != "ok") statusFailed = true
}
if (qName == "error") {
throw XmlFailure(
attributes.getValue("code")?.toIntOrNull() ?: -1,
attributes.getValue("message") ?: "Unknown error",
)
}
onElementStart(qName, attributes, depth)
} }
private fun Element.discTitles(): Map<Long, String> = override fun endElement(
childElements("discTitles") uri: String?,
.mapNotNull { title -> localName: String?,
val disc = title.longAttr("disc") ?: return@mapNotNull null qName: String,
title.attr("title").takeIf { it.isNotBlank() }?.let { disc to it } ) {
}.toMap() onElementEnd(qName, depth)
depth--
}
private fun Element.childElements(tag: String): List<Element> { override fun endDocument() {
val children = childNodes if (statusFailed) throw XmlFailure(-1, "Unknown error")
return (0 until children.length).mapNotNull { index -> }
(children.item(index) as? Element)?.takeIf { it.tagName == tag }
protected open fun onElementStart(
name: String,
attributes: Attributes,
depth: Int,
) = Unit
protected open fun onElementEnd(
name: String,
depth: Int,
) = Unit
}
private class StatusHandler : SubsonicResponseHandler()
private class EntityHandler<T>(
private val entityTag: String,
private val create: (Attributes) -> T,
private val onChild: (T, String, Attributes) -> Unit,
private val onEnd: (T) -> Unit,
private val stopAfterFirst: Boolean,
) : SubsonicResponseHandler() {
private var current: T? = null
private var entityDepth = -1
var found = false
private set
override fun onElementStart(
name: String,
attributes: Attributes,
depth: Int,
) {
val active = current
if (active == null) {
if (name == entityTag) {
current = create(attributes)
entityDepth = depth
}
} else if (depth == entityDepth + 1) {
onChild(active, name, attributes)
} }
} }
private fun Element.elements(tag: String): List<Element> { override fun onElementEnd(
val nodes = getElementsByTagName(tag) name: String,
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } depth: Int,
) {
val active = current ?: return
if (name == entityTag && depth == entityDepth) {
onEnd(active)
found = true
current = null
entityDepth = -1
if (stopAfterFirst) throw StopReading()
}
}
} }
private fun Document.elements(tag: String): List<Element> { private fun parse(
val nodes = getElementsByTagName(tag) input: InputStream,
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element } handler: DefaultHandler,
) {
try {
secureParserFactory().newSAXParser().parse(input, handler)
} catch (failure: XmlFailure) {
throw SubsonicException(failure.code, failure.message ?: "Unknown error")
} catch (_: StopReading) {
}
} }
private fun Element.attr(name: String): String = getAttribute(name) private fun secureParserFactory(): SAXParserFactory =
SAXParserFactory.newInstance().apply {
isNamespaceAware = false
isValidating = false
setFeatureQuietly("http://apache.org/xml/features/disallow-doctype-decl", true)
setFeatureQuietly("http://xml.org/sax/features/external-general-entities", false)
setFeatureQuietly("http://xml.org/sax/features/external-parameter-entities", false)
setFeatureQuietly("http://apache.org/xml/features/nonvalidating/load-external-dtd", false)
}
private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull() private fun SAXParserFactory.setFeatureQuietly(
name: String,
private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse) enabled: Boolean,
) {
try {
setFeature(name, enabled)
} catch (_: ParserConfigurationException) {
} catch (_: SAXException) {
}
}
internal object IsoDate { internal object IsoDate {
private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE) private val timezone = Regex("(Z|[+-]\\d{2}:?\\d{2})$", RegexOption.IGNORE_CASE)
@@ -36,12 +36,11 @@ class SyncManager(
private suspend fun runSync() { private suspend fun runSync() {
_status.value = SyncStatus.Running _status.value = SyncStatus.Running
_status.value = val result = sourceRepository.sync()
sourceRepository.sync().fold(
onSuccess = {
queueRepository.invalidateLibraryCache() queueRepository.invalidateLibraryCache()
SyncStatus.Success _status.value =
}, result.fold(
onSuccess = { SyncStatus.Success },
onFailure = { error -> onFailure = { error ->
Log.w(TAG, "Sync failed", error) Log.w(TAG, "Sync failed", error)
SyncStatus.Failed(error.message ?: "Sync failed") SyncStatus.Failed(error.message ?: "Sync failed")
@@ -24,62 +24,132 @@ class SyncService(
suspend fun sync() = suspend fun sync() =
lock.withLock { lock.withLock {
val artists = source.getArtists() syncArtists()
val albums = source.getAlbums() syncAlbums()
val songs = source.getSongs() syncSongs()
syncPlaylists()
syncPlaylistSongs()
}
private suspend fun syncArtists() {
val library = db.libraryDao()
val stale = library.artistIds(source.id).toHashSet()
source.artists().collect { batch ->
if (batch.isEmpty()) return@collect
write("artists", artistColumns, primaryKey, batch) { it.values() }
stale -= batch.mapTo(HashSet()) { it.id }
}
deleteStale(stale) { library.deleteArtists(source.id, it) }
}
private suspend fun syncAlbums() {
val library = db.libraryDao()
val staleAlbums = library.albumIds(source.id).toHashSet()
val staleDiscs = library.discKeys(source.id).toHashSet()
source.albums().collect { batch ->
if (batch.isEmpty()) return@collect
val discs = val discs =
albums.flatMap { album -> batch.flatMap { album ->
album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) } album.discTitles.map { (disc, title) -> Disc(album.sourceId, album.id, disc, title) }
} }
val playlists = source.getPlaylists() writeAlbumsAndDiscs(batch, discs)
val playlistSongs = source.getPlaylistSongs(playlists) staleAlbums -= batch.mapTo(HashSet()) { it.id }
staleDiscs -= discs.mapTo(HashSet()) { DiscKey(it.albumId, it.disc) }
db.useWriterConnection { transactor -> }
transactor.immediateTransaction { deleteStale(staleAlbums) { library.deleteAlbums(source.id, it) }
upsertChanged("artists", artistColumns, primaryKey, artists) { it.values() } deleteStaleDiscs(staleDiscs)
upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() } }
upsertChanged("discs", discColumns, discKey, discs) { it.values() }
upsertChanged("songs", songColumns, primaryKey, songs) { it.values() }
upsertChanged("playlists", playlistColumns, primaryKey, playlists) { it.values() }
upsertChanged("playlist_songs", playlistSongColumns, playlistSongKey, playlistSongs) { it.values() }
private suspend fun syncSongs() {
val library = db.libraryDao() val library = db.libraryDao()
val existingArtistIds = library.artistIds(source.id) val stale = library.songIds(source.id).toHashSet()
val existingAlbumIds = library.albumIds(source.id) source.songs().collect { batch ->
val existingSongIds = library.songIds(source.id) if (batch.isEmpty()) return@collect
val existingPlaylistIds = library.playlistIds(source.id) write("songs", songColumns, primaryKey, batch) { it.values() }
val existingDiscKeys = library.discKeys(source.id) stale -= batch.mapTo(HashSet()) { it.id }
prune(existingArtistIds, artists.map { it.id }) { library.deleteArtists(source.id, it) }
prune(existingAlbumIds, albums.map { it.id }) { library.deleteAlbums(source.id, it) }
prune(existingSongIds, songs.map { it.id }) { library.deleteSongs(source.id, it) }
prune(existingPlaylistIds, playlists.map { it.id }) { library.deletePlaylists(source.id, it) }
val currentDiscKeys = discs.mapTo(HashSet()) { DiscKey(it.albumId, it.disc) }
existingDiscKeys.filterNot { it in currentDiscKeys }.forEach { library.deleteDisc(source.id, it.albumId, it.disc) }
val playlistSongCounts = playlistSongs.groupingBy { it.playlistId }.eachCount()
playlists.forEach { playlist ->
library.deletePlaylistSongsFrom(source.id, playlist.id, (playlistSongCounts[playlist.id] ?: 0).toLong())
} }
(existingPlaylistIds - playlists.map { it.id }).forEach { library.deletePlaylistSongsFrom(source.id, it, 0) } deleteStale(stale) { library.deleteSongs(source.id, it) }
}
private suspend fun syncPlaylists() {
val library = db.libraryDao()
val stale = library.playlistIds(source.id).toHashSet()
source.playlists().collect { batch ->
if (batch.isEmpty()) return@collect
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
stale -= batch.mapTo(HashSet()) { it.id }
}
deleteStale(stale) { library.deletePlaylists(source.id, it) }
}
private suspend fun syncPlaylistSongs() {
val library = db.libraryDao()
val playlistIds = library.playlistIds(source.id)
val counts = HashMap<String, Long>()
source.playlistSongs(playlistIds).collect { batch ->
if (batch.isEmpty()) return@collect
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
}
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
library.deleteOrphanPlaylistSongs(source.id)
}
private suspend fun <T> write(
table: String,
columns: List<String>,
key: List<String>,
rows: List<T>,
values: (T) -> List<Any?>,
) {
if (rows.isEmpty()) return
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged(table, columns, key, rows, values)
} }
} }
} }
private suspend fun prune( private suspend fun writeAlbumsAndDiscs(
existing: List<String>, albums: List<Album>,
current: List<String>, discs: List<Disc>,
) {
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.upsertChanged("albums", albumColumns, primaryKey, albums) { it.values() }
connection.upsertChanged("discs", discColumns, discKey, discs) { it.values() }
}
}
}
private suspend fun deleteStaleDiscs(stale: Set<DiscKey>) {
if (stale.isEmpty()) return
db.useWriterConnection { connection ->
connection.immediateTransaction {
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
stale.forEach { key ->
statement.bind(1, source.id)
statement.bind(2, key.albumId)
statement.bind(3, key.disc)
statement.step()
statement.reset()
statement.clearBindings()
}
}
}
}
}
private suspend fun deleteStale(
stale: Set<String>,
delete: suspend (Collection<String>) -> Unit, delete: suspend (Collection<String>) -> Unit,
) { ) {
val currentIds = current.toHashSet() stale.chunked(DELETE_CHUNK).forEach { delete(it) }
existing
.filterNot { it in currentIds }
.chunked(500)
.forEach { delete(it) }
} }
} }
private const val DELETE_CHUNK = 500
private val primaryKey = listOf("sourceId", "id") private val primaryKey = listOf("sourceId", "id")
private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt") private val artistColumns = listOf("sourceId", "id", "name", "albumCount", "starred", "coverArt")
@@ -38,7 +38,7 @@ class SubsonicClientTest {
fun tokenAuthSendsSaltAndHash() { fun tokenAuthSendsSaltAndHash() {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")) server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
client(tokenAuth = true).get("ping") client(tokenAuth = true).check("ping")
val url = server.takeRequest().requestUrl!! val url = server.takeRequest().requestUrl!!
assertEquals("/rest/ping.view", url.encodedPath) assertEquals("/rest/ping.view", url.encodedPath)
@@ -53,7 +53,7 @@ class SubsonicClientTest {
fun plaintextAuthSendsPassword() { fun plaintextAuthSendsPassword() {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")) server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
client(tokenAuth = false).get("ping") client(tokenAuth = false).check("ping")
val url = server.takeRequest().requestUrl!! val url = server.takeRequest().requestUrl!!
assertEquals("secret", url.queryParameter("p")) assertEquals("secret", url.queryParameter("p"))
@@ -64,7 +64,7 @@ class SubsonicClientTest {
fun paramsAreForwarded() { fun paramsAreForwarded() {
server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>")) server.enqueue(MockResponse().setBody("<subsonic-response status=\"ok\" version=\"1.16.1\"/>"))
client(tokenAuth = true).get("getCoverArt", mapOf("id" to "cov", "size" to "256")) client(tokenAuth = true).check("getCoverArt", mapOf("id" to "cov", "size" to "256"))
val url = server.takeRequest().requestUrl!! val url = server.takeRequest().requestUrl!!
assertEquals("cov", url.queryParameter("id")) assertEquals("cov", url.queryParameter("id"))
@@ -79,7 +79,7 @@ class SubsonicClientTest {
), ),
) )
val error = assertThrows(SubsonicException::class.java) { client(tokenAuth = true).get("ping") } val error = assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") }
assertEquals(40, error.code) assertEquals(40, error.code)
assertEquals("Wrong username", error.message) assertEquals("Wrong username", error.message)
} }
@@ -88,6 +88,6 @@ class SubsonicClientTest {
fun nonSubsonicResponseThrows() { fun nonSubsonicResponseThrows() {
server.enqueue(MockResponse().setBody("<html><body>Sign in</body></html>")) server.enqueue(MockResponse().setBody("<html><body>Sign in</body></html>"))
assertThrows(SubsonicException::class.java) { client(tokenAuth = true).get("ping") } assertThrows(SubsonicException::class.java) { client(tokenAuth = true).check("ping") }
} }
} }
@@ -1,5 +1,6 @@
package com.subtracks.data.source.subsonic package com.subtracks.data.source.subsonic
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.mockwebserver.Dispatcher import okhttp3.mockwebserver.Dispatcher
@@ -53,7 +54,33 @@ class SubsonicSourceTest {
} }
val source = SubsonicSource(1, client()) val source = SubsonicSource(1, client())
assertEquals(listOf("s1", "s2"), source.getSongs().map { it.id }) assertEquals(
listOf("s1", "s2"),
source
.songs()
.toList()
.flatten()
.map { it.id },
)
}
@Test
fun artistsAreEmittedInBoundedBatches() =
runBlocking {
val count = 1200
val body =
buildString {
append("<subsonic-response status=\"ok\"><artists>")
repeat(count) { index ->
append("<artist id=\"ar$index\" name=\"Artist $index\" albumCount=\"1\"/>")
}
append("</artists></subsonic-response>")
}
server.enqueue(MockResponse().setBody(body))
val sizes = SubsonicSource(1, client()).artists().toList().map { it.size }
assertEquals(listOf(500, 500, 200), sizes)
} }
@Test @Test
@@ -2,19 +2,37 @@ package com.subtracks.data.source.subsonic
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Test import org.junit.Test
import org.w3c.dom.Document import org.xml.sax.Attributes
import javax.xml.parsers.DocumentBuilderFactory
class SubsonicXmlTest { class SubsonicXmlTest {
private fun parse(xml: String): Document = private fun <T> parse(
DocumentBuilderFactory xml: String,
.newInstance() tag: String,
.newDocumentBuilder() create: (Attributes) -> T,
.parse(xml.byteInputStream(Charsets.UTF_8)) onChild: (T, String, Attributes) -> Unit = { _, _, _ -> },
): List<T> {
val result = mutableListOf<T>()
SubsonicXml.readEntities(
input = xml.byteInputStream(Charsets.UTF_8),
entityTag = tag,
create = create,
onChild = onChild,
onEnd = { result += it },
)
return result
}
private fun albums(xml: String) =
parse(
xml,
tag = "album",
create = { SubsonicXml.albumDraft(1, it) },
onChild = { draft, name, attrs -> if (name == "discTitles") draft.addDiscTitle(attrs) },
).map { it.toAlbum() }
@Test @Test
fun mapsArtists() { fun mapsArtists() {
val document = val artists =
parse( parse(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
@@ -24,10 +42,10 @@ class SubsonicXmlTest {
</artists> </artists>
</subsonic-response> </subsonic-response>
""".trimIndent(), """.trimIndent(),
tag = "artist",
create = { SubsonicXml.artist(7, it) },
) )
val artists = SubsonicXml.artists(7, document)
assertEquals(2, artists.size) assertEquals(2, artists.size)
assertEquals("Radiohead", artists[0].name) assertEquals("Radiohead", artists[0].name)
assertEquals(7L, artists[0].sourceId) assertEquals(7L, artists[0].sourceId)
@@ -38,8 +56,8 @@ class SubsonicXmlTest {
@Test @Test
fun mapsAlbumList() { fun mapsAlbumList() {
val document = val albums =
parse( albums(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<albumList2> <albumList2>
@@ -50,8 +68,6 @@ class SubsonicXmlTest {
""".trimIndent(), """.trimIndent(),
) )
val albums = SubsonicXml.albums(1, document)
assertEquals(2, albums.size) assertEquals(2, albums.size)
assertEquals("OK Computer", albums[0].name) assertEquals("OK Computer", albums[0].name)
assertEquals("Kid A", albums[1].name) assertEquals("Kid A", albums[1].name)
@@ -62,8 +78,8 @@ class SubsonicXmlTest {
@Test @Test
fun mapsAlbumDiscTitles() { fun mapsAlbumDiscTitles() {
val document = val albums =
parse( albums(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<albumList2> <albumList2>
@@ -77,8 +93,6 @@ class SubsonicXmlTest {
""".trimIndent(), """.trimIndent(),
) )
val albums = SubsonicXml.albums(1, document)
assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles) assertEquals(mapOf(1L to "The Calm", 2L to "The Storm"), albums.single().discTitles)
} }
@@ -94,8 +108,7 @@ class SubsonicXmlTest {
@Test @Test
fun mapsSongsAndPlaylistEntries() { fun mapsSongsAndPlaylistEntries() {
val document = val xml =
parse(
""" """
<subsonic-response status="ok"> <subsonic-response status="ok">
<searchResult3> <searchResult3>
@@ -105,17 +118,42 @@ class SubsonicXmlTest {
<entry id="sg1" title="Everything In Its Right Place" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="251" track="1" discNumber="1"/> <entry id="sg1" title="Everything In Its Right Place" artist="Radiohead" album="Kid A" albumId="al2" artistId="ar1" duration="251" track="1" discNumber="1"/>
</playlist> </playlist>
</subsonic-response> </subsonic-response>
""".trimIndent(), """.trimIndent()
)
val songs = SubsonicXml.songs(1, document) val songs = parse(xml, tag = "song", create = { SubsonicXml.song(1, it) })
val entries = SubsonicXml.playlistSongs(1, "pl1", document) var position = 0L
val entries = parse(xml, tag = "entry", create = { SubsonicXml.playlistSong(1, "pl1", position++, it) })
assertEquals(1, songs.size) assertEquals(1, songs.size)
assertEquals("Idioteque", songs[0].title) assertEquals("Idioteque", songs[0].title)
assertEquals(300L, songs[0].duration) assertEquals(300L, songs[0].duration)
assertEquals(0L, entries[0].position) assertEquals(0L, entries[0].position)
assertEquals("sg1", entries[0].songId) assertEquals("sg1", entries[0].songId)
assertEquals(251L, SubsonicXml.song(1, document.getElementsByTagName("entry").item(0) as org.w3c.dom.Element).duration) }
@Test
fun statusOnlyReadRejectsAnHtmlLoginPage() {
val failure =
runCatching {
SubsonicXml.readStatus("<html><body>Sign in</body></html>".byteInputStream(Charsets.UTF_8))
}
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
}
@Test
fun containsEntityStopsAtTheFirstMatch() {
val xml =
"""
<subsonic-response status="ok">
<searchResult3>
<song id="s1" title="One"/>
<song id="s2" title="Two"/>
</searchResult3>
</subsonic-response>
""".trimIndent()
assertEquals(true, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "song"))
assertEquals(false, SubsonicXml.containsEntity(xml.byteInputStream(Charsets.UTF_8), "artist"))
} }
} }
@@ -14,7 +14,9 @@ import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song import com.subtracks.data.model.Song
import com.subtracks.data.model.Source import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource import com.subtracks.data.source.MusicSource
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.After import org.junit.After
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
@@ -293,6 +295,103 @@ class SyncServiceTest {
assertEquals(mapOf(1L to "New"), storedDiscs("al1")) assertEquals(mapOf(1L to "New"), storedDiscs("al1"))
} }
@Test
fun syncCommitsMultipleBatchesAndPrunes() =
runTest {
insertSource()
val source =
FakeMusicSource(
artists = (1..5).map { artist("a$it") },
songs = (1..5).map { song("s$it") },
batchSize = 2,
)
SyncService(db, source).sync()
assertEquals((1..5).map { "a$it" }, db.libraryDao().artistIds(1).sorted())
assertEquals((1..5).map { "s$it" }, db.libraryDao().songIds(1).sorted())
source.artists = (1..3).map { artist("a$it") }
source.songs = (1..3).map { song("s$it") }
SyncService(db, source).sync()
assertEquals((1..3).map { "a$it" }, db.libraryDao().artistIds(1).sorted())
assertEquals((1..3).map { "s$it" }, db.libraryDao().songIds(1).sorted())
}
@Test
fun playlistSongsSplitAcrossBatchesKeepTheirTail() =
runTest {
insertSource()
val source =
FakeMusicSource(
songs = (1..5).map { song("s$it") },
playlists = listOf(playlist("p1")),
playlistSongs = (1..5).map { PlaylistSong(1, "p1", "s$it", (it - 1).toLong()) },
batchSize = 2,
)
SyncService(db, source).sync()
assertEquals(
5,
db
.libraryDao()
.playlistSongs(1, "p1")
.allRows()
.size,
)
source.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,
)
}
@Test
fun removingAPlaylistRemovesItsSongs() =
runTest {
insertSource()
val source =
FakeMusicSource(
songs = listOf(song("s1")),
playlists = listOf(playlist("p1")),
playlistSongs = listOf(PlaylistSong(1, "p1", "s1", 0)),
)
SyncService(db, source).sync()
assertEquals(
1,
db
.libraryDao()
.playlistSongs(1, "p1")
.allRows()
.size,
)
source.playlists = emptyList()
source.playlistSongs = emptyList()
SyncService(db, source).sync()
assertEquals(0, db.libraryDao().playlistIds(1).size)
assertEquals(
0,
db
.libraryDao()
.playlistSongs(1, "p1")
.allRows()
.size,
)
}
private suspend fun storedDiscs(albumId: String): Map<Long, String> = private suspend fun storedDiscs(albumId: String): Map<Long, String> =
db db
.libraryDao() .libraryDao()
@@ -359,18 +458,24 @@ private class FakeMusicSource(
var songs: List<Song> = emptyList(), var songs: List<Song> = emptyList(),
var playlists: List<Playlist> = emptyList(), var playlists: List<Playlist> = emptyList(),
var playlistSongs: List<PlaylistSong> = emptyList(), var playlistSongs: List<PlaylistSong> = emptyList(),
private val batchSize: Int = 1000,
) : MusicSource { ) : MusicSource {
override val id: Long = 1 override val id: Long = 1
override suspend fun ping() = Unit override suspend fun ping() = Unit
override suspend fun getArtists(): List<Artist> = artists override fun artists(): Flow<List<Artist>> = batches(artists)
override suspend fun getAlbums(): List<Album> = albums override fun albums(): Flow<List<Album>> = batches(albums)
override suspend fun getSongs(): List<Song> = songs override fun songs(): Flow<List<Song>> = batches(songs)
override suspend fun getPlaylists(): List<Playlist> = playlists override fun playlists(): Flow<List<Playlist>> = batches(playlists)
override suspend fun getPlaylistSongs(playlists: List<Playlist>): List<PlaylistSong> = playlistSongs override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = batches(playlistSongs)
private fun <T> batches(items: List<T>): Flow<List<T>> =
flow {
items.chunked(batchSize).forEach { emit(it) }
}
} }