mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
address arch review: guard the page cap, coalesce syncs, tighten buffers
- Paged loops now throw when they hit maxPages with a full last page, so a truncated enumeration aborts the entity's collect and skips its prune instead of deleting rows the sync never saw. The cap is injectable for tests. - Move sync serialization into SyncManager (tryLock coalesces duplicate requests) and construct SyncService there; SourceRepository only exposes the active MusicSource. Removes the repo <-> sync import cycle. - Re-throw CancellationException instead of reporting a cancelled sync as failed. - Add buffer(1) to the outer albums/songs/playlistSongs flows so their flowOn channel cannot hold 64 batches. - Rank prefetch is best-effort (missing frequent/recent no longer aborts the album sync). - Skip playlist entries without an id, keeping positions contiguous. - Chunk stale-disc deletes like the other prunes. - Update the implementation plan line that still described the old single-transaction sync.
This commit is contained in:
@@ -6,8 +6,8 @@ import com.subtracks.data.model.Source
|
|||||||
import com.subtracks.data.model.SubsonicConfig
|
import com.subtracks.data.model.SubsonicConfig
|
||||||
import com.subtracks.data.model.SubsonicSource
|
import com.subtracks.data.model.SubsonicSource
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.data.source.MusicSource
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
import com.subtracks.data.sync.SyncService
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -16,8 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
|
||||||
import kotlinx.coroutines.sync.withLock
|
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
@@ -29,7 +27,6 @@ class SourceRepository(
|
|||||||
private val prefs: UserPreferences,
|
private val prefs: UserPreferences,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
private val syncLock = Mutex()
|
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: SubsonicMusicSource? = null
|
private var active: SubsonicMusicSource? = null
|
||||||
@@ -118,13 +115,10 @@ class SourceRepository(
|
|||||||
runCatching { client(address, username, password, useTokenAuth).check("ping") }
|
runCatching { client(address, username, password, useTokenAuth).check("ping") }
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun sync(): Result<Unit> =
|
suspend fun activeMusicSource(): MusicSource? {
|
||||||
syncLock.withLock {
|
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
||||||
runCatching {
|
return config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())
|
||||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
|
}
|
||||||
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun SubsonicConfig.toMusicSource(
|
private fun SubsonicConfig.toMusicSource(
|
||||||
maxBitrate: Int,
|
maxBitrate: Int,
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ class SubsonicSource(
|
|||||||
private val client: SubsonicClient,
|
private val client: SubsonicClient,
|
||||||
private val maxBitrate: Int = 0,
|
private val maxBitrate: Int = 0,
|
||||||
private val streamFormat: String? = null,
|
private val streamFormat: String? = null,
|
||||||
|
private val maxPages: Int = MAX_PAGES,
|
||||||
) : MusicSource {
|
) : MusicSource {
|
||||||
private var emptyQuerySearchSupported: Boolean? = null
|
private var emptyQuerySearchSupported: Boolean? = null
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ class SubsonicSource(
|
|||||||
val (frequent, recent) = fetchRanks()
|
val (frequent, recent) = fetchRanks()
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (pages < MAX_PAGES) {
|
while (pages < maxPages) {
|
||||||
var count = 0
|
var count = 0
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getAlbumList2",
|
method = "getAlbumList2",
|
||||||
@@ -69,7 +70,8 @@ class SubsonicSource(
|
|||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
pages++
|
pages++
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
checkPageCap(pages)
|
||||||
|
}.flowOn(Dispatchers.IO).buffer(1)
|
||||||
|
|
||||||
override fun songs(): Flow<List<Song>> =
|
override fun songs(): Flow<List<Song>> =
|
||||||
flow {
|
flow {
|
||||||
@@ -78,7 +80,7 @@ class SubsonicSource(
|
|||||||
} else {
|
} else {
|
||||||
emitAll(albumSongs())
|
emitAll(albumSongs())
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO).buffer(1)
|
||||||
|
|
||||||
override fun playlists(): Flow<List<Playlist>> =
|
override fun playlists(): Flow<List<Playlist>> =
|
||||||
entityBatches(
|
entityBatches(
|
||||||
@@ -96,10 +98,14 @@ class SubsonicSource(
|
|||||||
method = "getPlaylist",
|
method = "getPlaylist",
|
||||||
params = mapOf("id" to playlistId),
|
params = mapOf("id" to playlistId),
|
||||||
tag = "entry",
|
tag = "entry",
|
||||||
create = { attrs -> SubsonicXml.playlistSong(id, playlistId, position++, attrs) },
|
create = { attrs ->
|
||||||
|
val entryId = attrs.attr("id")
|
||||||
|
SubsonicXml.playlistSong(id, playlistId, if (entryId.isEmpty()) -1L else position++, attrs)
|
||||||
|
},
|
||||||
|
accept = { it.songId.isNotEmpty() },
|
||||||
).collect { emit(it) }
|
).collect { emit(it) }
|
||||||
}
|
}
|
||||||
}.flowOn(Dispatchers.IO)
|
}.flowOn(Dispatchers.IO).buffer(1)
|
||||||
|
|
||||||
fun streamUri(songId: String) =
|
fun streamUri(songId: String) =
|
||||||
client.uri(
|
client.uri(
|
||||||
@@ -132,7 +138,7 @@ class SubsonicSource(
|
|||||||
flow {
|
flow {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (pages < MAX_PAGES) {
|
while (pages < maxPages) {
|
||||||
var count = 0
|
var count = 0
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "search3",
|
method = "search3",
|
||||||
@@ -155,13 +161,14 @@ class SubsonicSource(
|
|||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
pages++
|
pages++
|
||||||
}
|
}
|
||||||
|
checkPageCap(pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun albumSongs(): Flow<List<Song>> =
|
private fun albumSongs(): Flow<List<Song>> =
|
||||||
flow {
|
flow {
|
||||||
var offset = 0
|
var offset = 0
|
||||||
var pages = 0
|
var pages = 0
|
||||||
while (pages < MAX_PAGES) {
|
while (pages < maxPages) {
|
||||||
val albums = ArrayList<String>(PAGE_SIZE)
|
val albums = ArrayList<String>(PAGE_SIZE)
|
||||||
entityBatches(
|
entityBatches(
|
||||||
method = "getAlbumList2",
|
method = "getAlbumList2",
|
||||||
@@ -183,34 +190,39 @@ class SubsonicSource(
|
|||||||
offset += PAGE_SIZE
|
offset += PAGE_SIZE
|
||||||
pages++
|
pages++
|
||||||
}
|
}
|
||||||
|
checkPageCap(pages)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchRanks(): Pair<Map<String, Long>, Map<String, Long>> = fetchRank("frequent") to fetchRank("recent")
|
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> {
|
private suspend fun fetchRank(type: String): Map<String, Long> =
|
||||||
val ranks = HashMap<String, Long>()
|
try {
|
||||||
var offset = 0
|
val ranks = HashMap<String, Long>()
|
||||||
var pages = 0
|
var offset = 0
|
||||||
while (pages < MAX_PAGES) {
|
var pages = 0
|
||||||
var index = 0
|
while (pages < maxPages) {
|
||||||
entityBatches(
|
var index = 0
|
||||||
method = "getAlbumList2",
|
entityBatches(
|
||||||
params = page(type, offset),
|
method = "getAlbumList2",
|
||||||
tag = "album",
|
params = page(type, offset),
|
||||||
create = { it.attr("id") },
|
tag = "album",
|
||||||
accept = { it.isNotEmpty() },
|
create = { it.attr("id") },
|
||||||
).collect { batch ->
|
accept = { it.isNotEmpty() },
|
||||||
batch.forEach { albumId ->
|
).collect { batch ->
|
||||||
ranks[albumId] = (offset + index).toLong()
|
batch.forEach { albumId ->
|
||||||
index++
|
ranks[albumId] = (offset + index).toLong()
|
||||||
|
index++
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
if (index < PAGE_SIZE) break
|
||||||
|
offset += PAGE_SIZE
|
||||||
|
pages++
|
||||||
}
|
}
|
||||||
if (index < PAGE_SIZE) break
|
checkPageCap(pages)
|
||||||
offset += PAGE_SIZE
|
ranks
|
||||||
pages++
|
} catch (_: SubsonicException) {
|
||||||
|
emptyMap()
|
||||||
}
|
}
|
||||||
return ranks
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun supportsEmptyQuerySearch(): Boolean {
|
private fun supportsEmptyQuerySearch(): Boolean {
|
||||||
emptyQuerySearchSupported?.let { return it }
|
emptyQuerySearchSupported?.let { return it }
|
||||||
@@ -227,6 +239,10 @@ class SubsonicSource(
|
|||||||
return supported
|
return supported
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun checkPageCap(pages: Int) {
|
||||||
|
if (pages >= maxPages) throw SubsonicException(-1, "Library exceeds the $maxPages page cap")
|
||||||
|
}
|
||||||
|
|
||||||
private fun <T> entityBatches(
|
private fun <T> entityBatches(
|
||||||
method: String,
|
method: String,
|
||||||
params: Map<String, String> = emptyMap(),
|
params: Map<String, String> = emptyMap(),
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.sync.Mutex
|
||||||
|
|
||||||
sealed interface SyncStatus {
|
sealed interface SyncStatus {
|
||||||
data object Idle : SyncStatus
|
data object Idle : SyncStatus
|
||||||
@@ -23,20 +26,38 @@ sealed interface SyncStatus {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class SyncManager(
|
class SyncManager(
|
||||||
|
private val db: SubtracksDatabase,
|
||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
private val syncLock = Mutex()
|
||||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||||
val status: StateFlow<SyncStatus> = _status
|
val status: StateFlow<SyncStatus> = _status
|
||||||
|
|
||||||
fun requestSync() {
|
fun requestSync() {
|
||||||
scope.launch { runSync() }
|
if (!syncLock.tryLock()) return
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
runSync()
|
||||||
|
} finally {
|
||||||
|
syncLock.unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync() {
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running
|
||||||
val result = sourceRepository.sync()
|
val result =
|
||||||
|
try {
|
||||||
|
val source = sourceRepository.activeMusicSource() ?: error("No server configured")
|
||||||
|
SyncService(db, source).sync()
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Result.failure(failure)
|
||||||
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value =
|
_status.value =
|
||||||
result.fold(
|
result.fold(
|
||||||
|
|||||||
@@ -117,17 +117,18 @@ class SyncService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun deleteStaleDiscs(stale: Set<DiscKey>) {
|
private suspend fun deleteStaleDiscs(stale: Set<DiscKey>) {
|
||||||
if (stale.isEmpty()) return
|
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
||||||
db.useWriterConnection { connection ->
|
db.useWriterConnection { connection ->
|
||||||
connection.immediateTransaction {
|
connection.immediateTransaction {
|
||||||
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
||||||
stale.forEach { key ->
|
chunk.forEach { key ->
|
||||||
statement.bind(1, source.id)
|
statement.bind(1, source.id)
|
||||||
statement.bind(2, key.albumId)
|
statement.bind(2, key.albumId)
|
||||||
statement.bind(3, key.disc)
|
statement.bind(3, key.disc)
|
||||||
statement.step()
|
statement.step()
|
||||||
statement.reset()
|
statement.reset()
|
||||||
statement.clearBindings()
|
statement.clearBindings()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ fun appModule(
|
|||||||
single { SourceRepository(get(), get(), get()) }
|
single { SourceRepository(get(), get(), get()) }
|
||||||
single { LibraryRepository(get(), get()) }
|
single { LibraryRepository(get(), get()) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get()) }
|
single { SyncManager(get(), get(), get()) }
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||||
single { PlaybackController(get(), get(), get()) }
|
single { PlaybackController(get(), get(), get()) }
|
||||||
|
|||||||
@@ -179,6 +179,49 @@ class SubsonicSourceTest {
|
|||||||
assertEquals(500, firstBatch.size)
|
assertEquals(500, firstBatch.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun albumsAbortWhenThePageCapIsHit() =
|
||||||
|
runBlocking {
|
||||||
|
server.dispatcher =
|
||||||
|
object : Dispatcher() {
|
||||||
|
override fun dispatch(request: RecordedRequest): MockResponse {
|
||||||
|
val url = request.requestUrl!!
|
||||||
|
if (url.encodedPath != "/rest/getAlbumList2.view") return MockResponse().setResponseCode(404)
|
||||||
|
return if (url.queryParameter("type") == "newest") {
|
||||||
|
MockResponse().setBody(albumPage(0, 500))
|
||||||
|
} else {
|
||||||
|
MockResponse().setBody(emptyAlbumList())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val failure =
|
||||||
|
runCatching {
|
||||||
|
SubsonicSource(1, client(), maxPages = 2).albums().toList()
|
||||||
|
}
|
||||||
|
|
||||||
|
assertEquals(true, failure.exceptionOrNull() is SubsonicException)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun playlistEntriesWithoutIdsAreSkipped() =
|
||||||
|
runBlocking {
|
||||||
|
server.enqueue(
|
||||||
|
MockResponse().setBody(
|
||||||
|
"<subsonic-response status=\"ok\"><playlist id=\"p1\">" +
|
||||||
|
"<entry id=\"s1\" title=\"One\"/>" +
|
||||||
|
"<entry title=\"No id\"/>" +
|
||||||
|
"<entry id=\"s2\" title=\"Two\"/>" +
|
||||||
|
"</playlist></subsonic-response>",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
val entries = SubsonicSource(1, client()).playlistSongs(listOf("p1")).toList().flatten()
|
||||||
|
|
||||||
|
assertEquals(listOf("s1", "s2"), entries.map { it.songId })
|
||||||
|
assertEquals(listOf(0L, 1L), entries.map { it.position })
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
|
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
|
||||||
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
|
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ class SyncManagerTest {
|
|||||||
.build()
|
.build()
|
||||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||||
queueRepository = QueueRepository(db)
|
queueRepository = QueueRepository(db)
|
||||||
manager = SyncManager(sourceRepository, queueRepository)
|
manager = SyncManager(db, sourceRepository, queueRepository)
|
||||||
}
|
}
|
||||||
|
|
||||||
@After
|
@After
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ Everything needed to mirror a server library locally:
|
|||||||
|
|
||||||
- `MusicSource` abstraction with `SubsonicClient`, `SubsonicXml` and `SubsonicSource`.
|
- `MusicSource` abstraction with `SubsonicClient`, `SubsonicXml` and `SubsonicSource`.
|
||||||
- Room 3 schema on bundled SQLite, so the SQLite version is identical on every device.
|
- Room 3 schema on bundled SQLite, so the SQLite version is identical on every device.
|
||||||
- `SyncService`: fetch first, then a single transaction of upserts with diff-based pruning.
|
- `SyncService`: stream each entity in batches, upserting per batch and pruning the IDs the server no longer lists; the sync is not atomic.
|
||||||
- Unit tests (client, XML, sync), native integration tests against navidrome and gonic, and CI (`unit`, `lint`, `integration`) with warm caches.
|
- Unit tests (client, XML, sync), native integration tests against navidrome and gonic, and CI (`unit`, `lint`, `integration`) with warm caches.
|
||||||
|
|
||||||
## Phase 2 - UI vertical slice (done)
|
## Phase 2 - UI vertical slice (done)
|
||||||
|
|||||||
Reference in New Issue
Block a user