mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
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:
+11
-7
@@ -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,148 +5,306 @@ 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endElement(
|
||||||
|
uri: String?,
|
||||||
|
localName: String?,
|
||||||
|
qName: String,
|
||||||
|
) {
|
||||||
|
onElementEnd(qName, depth)
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun endDocument() {
|
||||||
|
if (statusFailed) throw XmlFailure(-1, "Unknown error")
|
||||||
|
}
|
||||||
|
|
||||||
|
protected open fun onElementStart(
|
||||||
|
name: String,
|
||||||
|
attributes: Attributes,
|
||||||
|
depth: Int,
|
||||||
|
) = Unit
|
||||||
|
|
||||||
|
protected open fun onElementEnd(
|
||||||
|
name: String,
|
||||||
|
depth: Int,
|
||||||
|
) = Unit
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Element.discTitles(): Map<Long, String> =
|
private class StatusHandler : SubsonicResponseHandler()
|
||||||
childElements("discTitles")
|
|
||||||
.mapNotNull { title ->
|
|
||||||
val disc = title.longAttr("disc") ?: return@mapNotNull null
|
|
||||||
title.attr("title").takeIf { it.isNotBlank() }?.let { disc to it }
|
|
||||||
}.toMap()
|
|
||||||
|
|
||||||
private fun Element.childElements(tag: String): List<Element> {
|
private class EntityHandler<T>(
|
||||||
val children = childNodes
|
private val entityTag: String,
|
||||||
return (0 until children.length).mapNotNull { index ->
|
private val create: (Attributes) -> T,
|
||||||
(children.item(index) as? Element)?.takeIf { it.tagName == tag }
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onElementEnd(
|
||||||
|
name: String,
|
||||||
|
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 Element.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 Document.elements(tag: String): List<Element> {
|
private fun secureParserFactory(): SAXParserFactory =
|
||||||
val nodes = getElementsByTagName(tag)
|
SAXParserFactory.newInstance().apply {
|
||||||
return (0 until nodes.length).mapNotNull { nodes.item(it) as? Element }
|
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 SAXParserFactory.setFeatureQuietly(
|
||||||
|
name: String,
|
||||||
|
enabled: Boolean,
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
setFeature(name, enabled)
|
||||||
|
} catch (_: ParserConfigurationException) {
|
||||||
|
} catch (_: SAXException) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Element.attr(name: String): String = getAttribute(name)
|
|
||||||
|
|
||||||
private fun Element.longAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.toLongOrNull()
|
|
||||||
|
|
||||||
private fun Element.dateAttr(name: String): Long? = getAttribute(name).takeIf { it.isNotEmpty() }?.let(IsoDate::parse)
|
|
||||||
|
|
||||||
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)
|
||||||
private val longFraction = Regex("\\.(\\d{9})\\d+")
|
private val longFraction = Regex("\\.(\\d{9})\\d+")
|
||||||
|
|||||||
@@ -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) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user