mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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.SubsonicSource
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.data.sync.SyncService
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -16,8 +16,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
@@ -29,7 +27,6 @@ class SourceRepository(
|
||||
private val prefs: UserPreferences,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val syncLock = Mutex()
|
||||
|
||||
@Volatile
|
||||
private var active: SubsonicMusicSource? = null
|
||||
@@ -118,13 +115,10 @@ class SourceRepository(
|
||||
runCatching { client(address, username, password, useTokenAuth).check("ping") }
|
||||
}
|
||||
|
||||
suspend fun sync(): Result<Unit> =
|
||||
syncLock.withLock {
|
||||
runCatching {
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: error("No server configured")
|
||||
SyncService(db, config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())).sync()
|
||||
}
|
||||
}
|
||||
suspend fun activeMusicSource(): MusicSource? {
|
||||
val config = db.sourcesDao().activeSubsonicConfigOnce() ?: return null
|
||||
return config.toMusicSource(prefs.maxBitrate.first(), prefs.streamFormat.first())
|
||||
}
|
||||
|
||||
private fun SubsonicConfig.toMusicSource(
|
||||
maxBitrate: Int,
|
||||
|
||||
@@ -25,6 +25,7 @@ class SubsonicSource(
|
||||
private val client: SubsonicClient,
|
||||
private val maxBitrate: Int = 0,
|
||||
private val streamFormat: String? = null,
|
||||
private val maxPages: Int = MAX_PAGES,
|
||||
) : MusicSource {
|
||||
private var emptyQuerySearchSupported: Boolean? = null
|
||||
|
||||
@@ -45,7 +46,7 @@ class SubsonicSource(
|
||||
val (frequent, recent) = fetchRanks()
|
||||
var offset = 0
|
||||
var pages = 0
|
||||
while (pages < MAX_PAGES) {
|
||||
while (pages < maxPages) {
|
||||
var count = 0
|
||||
entityBatches(
|
||||
method = "getAlbumList2",
|
||||
@@ -69,7 +70,8 @@ class SubsonicSource(
|
||||
offset += PAGE_SIZE
|
||||
pages++
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
checkPageCap(pages)
|
||||
}.flowOn(Dispatchers.IO).buffer(1)
|
||||
|
||||
override fun songs(): Flow<List<Song>> =
|
||||
flow {
|
||||
@@ -78,7 +80,7 @@ class SubsonicSource(
|
||||
} else {
|
||||
emitAll(albumSongs())
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}.flowOn(Dispatchers.IO).buffer(1)
|
||||
|
||||
override fun playlists(): Flow<List<Playlist>> =
|
||||
entityBatches(
|
||||
@@ -96,10 +98,14 @@ class SubsonicSource(
|
||||
method = "getPlaylist",
|
||||
params = mapOf("id" to playlistId),
|
||||
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) }
|
||||
}
|
||||
}.flowOn(Dispatchers.IO)
|
||||
}.flowOn(Dispatchers.IO).buffer(1)
|
||||
|
||||
fun streamUri(songId: String) =
|
||||
client.uri(
|
||||
@@ -132,7 +138,7 @@ class SubsonicSource(
|
||||
flow {
|
||||
var offset = 0
|
||||
var pages = 0
|
||||
while (pages < MAX_PAGES) {
|
||||
while (pages < maxPages) {
|
||||
var count = 0
|
||||
entityBatches(
|
||||
method = "search3",
|
||||
@@ -155,13 +161,14 @@ class SubsonicSource(
|
||||
offset += PAGE_SIZE
|
||||
pages++
|
||||
}
|
||||
checkPageCap(pages)
|
||||
}
|
||||
|
||||
private fun albumSongs(): Flow<List<Song>> =
|
||||
flow {
|
||||
var offset = 0
|
||||
var pages = 0
|
||||
while (pages < MAX_PAGES) {
|
||||
while (pages < maxPages) {
|
||||
val albums = ArrayList<String>(PAGE_SIZE)
|
||||
entityBatches(
|
||||
method = "getAlbumList2",
|
||||
@@ -183,34 +190,39 @@ class SubsonicSource(
|
||||
offset += PAGE_SIZE
|
||||
pages++
|
||||
}
|
||||
checkPageCap(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++
|
||||
private suspend fun fetchRank(type: String): Map<String, Long> =
|
||||
try {
|
||||
val ranks = HashMap<String, Long>()
|
||||
var offset = 0
|
||||
var pages = 0
|
||||
while (pages < maxPages) {
|
||||
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
|
||||
pages++
|
||||
}
|
||||
if (index < PAGE_SIZE) break
|
||||
offset += PAGE_SIZE
|
||||
pages++
|
||||
checkPageCap(pages)
|
||||
ranks
|
||||
} catch (_: SubsonicException) {
|
||||
emptyMap()
|
||||
}
|
||||
return ranks
|
||||
}
|
||||
|
||||
private fun supportsEmptyQuerySearch(): Boolean {
|
||||
emptyQuerySearchSupported?.let { return it }
|
||||
@@ -227,6 +239,10 @@ class SubsonicSource(
|
||||
return supported
|
||||
}
|
||||
|
||||
private fun checkPageCap(pages: Int) {
|
||||
if (pages >= maxPages) throw SubsonicException(-1, "Library exceeds the $maxPages page cap")
|
||||
}
|
||||
|
||||
private fun <T> entityBatches(
|
||||
method: String,
|
||||
params: Map<String, String> = emptyMap(),
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.util.Log
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
|
||||
sealed interface SyncStatus {
|
||||
data object Idle : SyncStatus
|
||||
@@ -23,20 +26,38 @@ sealed interface SyncStatus {
|
||||
}
|
||||
|
||||
class SyncManager(
|
||||
private val db: SubtracksDatabase,
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
private val syncLock = Mutex()
|
||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
|
||||
fun requestSync() {
|
||||
scope.launch { runSync() }
|
||||
if (!syncLock.tryLock()) return
|
||||
scope.launch {
|
||||
try {
|
||||
runSync()
|
||||
} finally {
|
||||
syncLock.unlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
_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()
|
||||
_status.value =
|
||||
result.fold(
|
||||
|
||||
@@ -117,17 +117,18 @@ class SyncService(
|
||||
}
|
||||
|
||||
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()
|
||||
stale.chunked(DELETE_CHUNK).forEach { chunk ->
|
||||
db.useWriterConnection { connection ->
|
||||
connection.immediateTransaction {
|
||||
connection.usePrepared("DELETE FROM discs WHERE sourceId = ? AND albumId = ? AND disc = ?") { statement ->
|
||||
chunk.forEach { key ->
|
||||
statement.bind(1, source.id)
|
||||
statement.bind(2, key.albumId)
|
||||
statement.bind(3, key.disc)
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ fun appModule(
|
||||
single { SourceRepository(get(), get(), get()) }
|
||||
single { LibraryRepository(get(), get()) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get()) }
|
||||
single { SyncManager(get(), get(), get()) }
|
||||
single { QueueRepository(get()) }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get()) }
|
||||
single { PlaybackController(get(), get(), get()) }
|
||||
|
||||
@@ -179,6 +179,49 @@ class SubsonicSourceTest {
|
||||
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
|
||||
fun streamUriRequestsTranscodingWhenABitrateIsSet() {
|
||||
val uri = SubsonicSource(1, client(), maxBitrate = 128).streamUri("s1").toString()
|
||||
|
||||
@@ -37,7 +37,7 @@ class SyncManagerTest {
|
||||
.build()
|
||||
sourceRepository = SourceRepository(db, OkHttpClient(), fakeUserPreferences())
|
||||
queueRepository = QueueRepository(db)
|
||||
manager = SyncManager(sourceRepository, queueRepository)
|
||||
manager = SyncManager(db, sourceRepository, queueRepository)
|
||||
}
|
||||
|
||||
@After
|
||||
|
||||
@@ -12,7 +12,7 @@ Everything needed to mirror a server library locally:
|
||||
|
||||
- `MusicSource` abstraction with `SubsonicClient`, `SubsonicXml` and `SubsonicSource`.
|
||||
- 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.
|
||||
|
||||
## Phase 2 - UI vertical slice (done)
|
||||
|
||||
Reference in New Issue
Block a user